feat(droid): align BYOK provider mapping for ccsd

- add Droid provider resolver aligned with Factory BYOK schema

- persist and normalize CCS_DROID_PROVIDER across CLI and dashboard flows

- use Droid managed selector aliases with safe legacy fallback

- add unit coverage for provider inference and selector usage

Closes #632
This commit is contained in:
Tam Nhu Tran
2026-02-25 20:58:35 +07:00
parent e67ca539b2
commit 60b1c043c7
12 changed files with 421 additions and 16 deletions
+5
View File
@@ -157,6 +157,11 @@ ccsd glm
Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`).
For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint
(`CCS_DROID_PROVIDER`) using one of:
`anthropic`, `openai`, or `generic-chat-completion-api`.
If the hint is missing, CCS resolves provider from base URL/model at runtime.
### Per-Profile Target Defaults
You can pin a default target (`claude` or `droid`) per profile:
+21 -5
View File
@@ -13,6 +13,7 @@ import {
} from '../../config/unified-config-loader';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import type {
ModelMapping,
CreateApiProfileResult,
@@ -30,10 +31,16 @@ function createSettingsFile(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping
models: ModelMapping,
provider?: string
): string {
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
const droidProvider = resolveDroidProvider({
provider,
baseUrl,
model: models.default,
});
const settings = {
env: {
@@ -43,6 +50,7 @@ function createSettingsFile(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
CCS_DROID_PROVIDER: droidProvider,
// OpenRouter requires explicitly blanking the API key to prevent conflicts
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
},
@@ -97,11 +105,17 @@ function createApiProfileUnified(
baseUrl: string,
apiKey: string,
models: ModelMapping,
target: TargetType = 'claude'
target: TargetType = 'claude',
provider?: string
): void {
const ccsDir = getCcsDir();
const settingsFile = `${name}.settings.json`;
const settingsPath = path.join(ccsDir, settingsFile);
const droidProvider = resolveDroidProvider({
provider,
baseUrl,
model: models.default,
});
const settings = {
env: {
@@ -111,6 +125,7 @@ function createApiProfileUnified(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
CCS_DROID_PROVIDER: droidProvider,
// OpenRouter requires explicitly blanking the API key to prevent conflicts
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
},
@@ -140,15 +155,16 @@ export function createApiProfile(
baseUrl: string,
apiKey: string,
models: ModelMapping,
target: TargetType = 'claude'
target: TargetType = 'claude',
provider?: string
): CreateApiProfileResult {
try {
const settingsFile = `~/.ccs/${name}.settings.json`;
if (isUnifiedMode()) {
createApiProfileUnified(name, baseUrl, apiKey, models, target);
createApiProfileUnified(name, baseUrl, apiKey, models, target, provider);
} else {
createSettingsFile(name, baseUrl, apiKey, models);
createSettingsFile(name, baseUrl, apiKey, models, provider);
updateLegacyConfig(name, target);
}
+16 -1
View File
@@ -54,6 +54,7 @@ import {
ClaudeAdapter,
DroidAdapter,
pruneOrphanedModels,
resolveDroidProvider,
type TargetCredentials,
} from './targets';
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
@@ -841,7 +842,11 @@ async function main(): Promise<void> {
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
model: envVars['ANTHROPIC_MODEL'] || undefined,
provider: 'anthropic',
provider: resolveDroidProvider({
provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'],
baseUrl: envVars['ANTHROPIC_BASE_URL'],
model: envVars['ANTHROPIC_MODEL'],
}),
envVars,
};
@@ -1010,6 +1015,11 @@ async function main(): Promise<void> {
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
model: settingsEnv['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'],
model: settingsEnv['ANTHROPIC_MODEL'],
}),
};
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
@@ -1076,6 +1086,11 @@ async function main(): Promise<void> {
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
model: process.env['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'],
baseUrl: process.env['ANTHROPIC_BASE_URL'],
model: process.env['ANTHROPIC_MODEL'],
}),
};
if (!creds.baseUrl || !creds.apiKey) {
console.error(
+13 -4
View File
@@ -2,7 +2,7 @@
* Droid Adapter
*
* TargetAdapter implementation for Factory Droid CLI.
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-<profile>`.
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:<alias>`.
*/
import { spawn, ChildProcess } from 'child_process';
@@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from '
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
import type { ProfileType } from '../types/profile';
import { upsertCcsModel } from './droid-config-manager';
import { resolveDroidProvider } from './droid-provider';
import { escapeShellArg } from '../utils/shell-executor';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
@@ -18,6 +19,7 @@ import { runCleanup } from '../errors';
export class DroidAdapter implements TargetAdapter {
readonly type: TargetType = 'droid';
readonly displayName = 'Factory Droid';
private readonly modelSelectorsByProfile = new Map<string, string>();
private validateCredentials(creds: TargetCredentials): void {
if (!creds.baseUrl?.trim()) {
@@ -43,13 +45,19 @@ export class DroidAdapter implements TargetAdapter {
*/
async prepareCredentials(creds: TargetCredentials): Promise<void> {
this.validateCredentials(creds);
await upsertCcsModel(creds.profile, {
const provider = resolveDroidProvider({
provider: creds.provider,
baseUrl: creds.baseUrl,
model: creds.model,
});
const modelRef = await upsertCcsModel(creds.profile, {
model: creds.model || 'claude-opus-4-6',
displayName: `CCS ${creds.profile}`,
baseUrl: creds.baseUrl,
apiKey: creds.apiKey,
provider: creds.provider || 'anthropic',
provider,
});
this.modelSelectorsByProfile.set(creds.profile, modelRef.selector);
}
buildArgs(profile: string, userArgs: string[]): string[] {
@@ -58,7 +66,8 @@ export class DroidAdapter implements TargetAdapter {
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
);
}
return ['-m', `custom:ccs-${profile}`, ...userArgs];
const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`;
return ['-m', selector, ...userArgs];
}
/**
+41 -1
View File
@@ -43,6 +43,14 @@ export interface DroidCustomModel {
maxOutputTokens?: number;
}
export interface DroidManagedModelRef {
profile: string;
displayName: string;
index: number;
selectorAlias: string;
selector: string;
}
interface DroidSettings {
customModels?: DroidCustomModelEntry[];
[key: string]: unknown;
@@ -97,6 +105,11 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null {
return isDroidCustomModelEntry(value) ? value : null;
}
function buildSelectorAlias(displayName: string, index: number): string {
const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-');
return `${normalizedDisplayName}-${index}`;
}
function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] {
if (Array.isArray(value)) {
return value
@@ -302,11 +315,15 @@ function writeDroidSettings(settings: DroidSettings): void {
* Upsert a CCS-managed custom model entry.
* Acquires file lock to prevent concurrent write races.
*/
export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise<void> {
export async function upsertCcsModel(
profile: string,
model: DroidCustomModel
): Promise<DroidManagedModelRef> {
validateProfileName(profile);
ensureFactoryDir();
let release: (() => Promise<void>) | undefined;
let ref: DroidManagedModelRef | null = null;
try {
release = await acquireFactoryLock(10);
@@ -330,9 +347,32 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel):
}
writeDroidSettings(settings);
const index = settings.customModels.findIndex(
(entry) => parseManagedProfile(entry.displayName) === profile
);
const safeIndex = index >= 0 ? index : 0;
const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex);
ref = {
profile,
displayName: entry.displayName,
index: safeIndex,
selectorAlias,
selector: `custom:${selectorAlias}`,
};
} finally {
if (release) await release();
}
return (
ref || {
profile,
displayName: `CCS ${profile}`,
index: 0,
selectorAlias: `CCS-${profile}-0`,
selector: `custom:CCS-${profile}-0`,
}
);
}
/**
+148
View File
@@ -0,0 +1,148 @@
/**
* Droid BYOK provider resolution helpers.
*
* Factory BYOK accepts exactly:
* - anthropic
* - openai
* - generic-chat-completion-api
*
* CCS stores provider hints in profile settings as CCS_DROID_PROVIDER and
* resolves a best-effort provider from base URL/model when the hint is absent.
*/
export type DroidProvider = 'anthropic' | 'openai' | 'generic-chat-completion-api';
const GENERIC_PROVIDER_ALIASES = new Set<string>([
'generic',
'generic-openai',
'generic-openai-api',
'generic-chat',
'generic-chat-completions',
'openai-compatible',
'chat-completions',
]);
const OPENAI_PROVIDER_ALIASES = new Set<string>(['openai-responses', 'openai-official']);
const ANTHROPIC_PROVIDER_ALIASES = new Set<string>(['anthropic-compatible']);
/**
* Normalize potentially messy provider input into a valid Factory provider.
*/
export function normalizeDroidProvider(provider: string | undefined | null): DroidProvider | null {
if (!provider) return null;
const normalized = provider.trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'anthropic' || ANTHROPIC_PROVIDER_ALIASES.has(normalized)) {
return 'anthropic';
}
if (normalized === 'openai' || OPENAI_PROVIDER_ALIASES.has(normalized)) {
return 'openai';
}
if (normalized === 'generic-chat-completion-api' || GENERIC_PROVIDER_ALIASES.has(normalized)) {
return 'generic-chat-completion-api';
}
return null;
}
/**
* Infer provider primarily from base URL patterns used in BYOK configs.
*/
export function inferDroidProviderFromBaseUrl(
baseUrl: string | undefined | null
): DroidProvider | null {
if (!baseUrl) return null;
const raw = baseUrl.trim();
if (!raw) return null;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return null;
}
const host = parsed.host.toLowerCase();
const pathname = parsed.pathname.toLowerCase();
if (
host.includes('api.openai.com') ||
host.includes('.openai.azure.com') ||
host.includes('.services.ai.azure.com')
) {
return 'openai';
}
if (host.includes('anthropic.com') || pathname.includes('/anthropic')) {
return 'anthropic';
}
if (
host.includes('openrouter.ai') ||
host.includes('api.groq.com') ||
host.includes('api.deepinfra.com') ||
host.includes('api.fireworks.ai') ||
host.includes('inference.baseten.co') ||
host.includes('huggingface.co') ||
host.includes('ollama.com') ||
pathname.includes('/openai') ||
pathname.includes('/chat/completions')
) {
return 'generic-chat-completion-api';
}
return null;
}
/**
* Infer provider from model naming when URL does not provide a clear signal.
*/
export function inferDroidProviderFromModel(
model: string | undefined | null
): DroidProvider | null {
if (!model) return null;
const normalized = model.trim().toLowerCase();
if (!normalized) return null;
if (normalized.startsWith('claude-')) {
return 'anthropic';
}
if (
normalized.startsWith('gpt-') ||
normalized.startsWith('o1') ||
normalized.startsWith('o3') ||
normalized.startsWith('o4')
) {
return 'openai';
}
return null;
}
export interface DroidProviderResolveInput {
provider?: string | null;
baseUrl?: string | null;
model?: string | null;
}
/**
* Resolve a provider for Droid custom model entries.
*
* Precedence:
* 1) explicit provider hint (CCS_DROID_PROVIDER)
* 2) base URL inference
* 3) model inference
* 4) anthropic (backward-compatible default for legacy CCS profiles)
*/
export function resolveDroidProvider(input: DroidProviderResolveInput): DroidProvider {
const explicit = normalizeDroidProvider(input.provider);
if (explicit) return explicit;
const fromUrl = inferDroidProviderFromBaseUrl(input.baseUrl);
if (fromUrl) return fromUrl;
const fromModel = inferDroidProviderFromModel(input.model);
if (fromModel) return fromModel;
return 'anthropic';
}
+2
View File
@@ -27,4 +27,6 @@ export {
pruneOrphanedModels,
} from './droid-config-manager';
export type { DroidCustomModel } from './droid-config-manager';
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
export type { DroidProvider } from './droid-provider';
export { resolveTargetType, stripTargetFlag } from './target-resolver';
+30 -3
View File
@@ -14,6 +14,7 @@ import {
} from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
import type { TargetType } from '../../targets/target-adapter';
import { normalizeDroidProvider } from '../../targets/droid-provider';
import { updateSettingsFile } from './route-helpers';
const router = Router();
@@ -61,12 +62,20 @@ router.get('/', (_req: Request, res: Response): void => {
*/
router.post('/', (req: Request, res: Response): void => {
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint);
const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (providerHint !== undefined && parsedProvider === null) {
res.status(400).json({
error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api',
});
return;
}
if (!name || !baseUrl || !apiKey) {
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' });
@@ -99,7 +108,8 @@ router.post('/', (req: Request, res: Response): void => {
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
},
parsedTarget || 'claude'
parsedTarget || 'claude',
parsedProvider || undefined
);
if (!result.success) {
@@ -120,12 +130,20 @@ router.post('/', (req: Request, res: Response): void => {
router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint);
const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (providerHint !== undefined && parsedProvider === null) {
res.status(400).json({
error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api',
});
return;
}
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
@@ -150,7 +168,8 @@ router.put('/:name', (req: Request, res: Response): void => {
model !== undefined ||
opusModel !== undefined ||
sonnetModel !== undefined ||
haikuModel !== undefined;
haikuModel !== undefined ||
providerHint !== undefined;
const hasTargetUpdate = target !== undefined;
if (!hasSettingsUpdates && !hasTargetUpdate) {
@@ -159,7 +178,15 @@ router.put('/:name', (req: Request, res: Response): void => {
}
if (hasSettingsUpdates) {
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
updateSettingsFile(name, {
baseUrl,
apiKey,
model,
opusModel,
sonnetModel,
haikuModel,
provider: parsedProvider || undefined,
});
}
if (hasTargetUpdate && parsedTarget) {
+25 -1
View File
@@ -7,6 +7,7 @@ import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { resolveDroidProvider } from '../../targets/droid-provider';
import type { Config, Settings } from '../../types/config';
/** Model mapping for API profiles */
@@ -64,10 +65,16 @@ export function createSettingsFile(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping = {}
models: ModelMapping = {},
provider?: string
): string {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
const { model, opusModel, sonnetModel, haikuModel } = models;
const droidProvider = resolveDroidProvider({
provider,
baseUrl,
model,
});
const settings: Settings = {
env: {
@@ -77,6 +84,7 @@ export function createSettingsFile(
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }),
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }),
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }),
CCS_DROID_PROVIDER: droidProvider,
},
};
@@ -96,6 +104,7 @@ export function updateSettingsFile(
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
provider?: string;
}
): void {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
@@ -153,6 +162,21 @@ export function updateSettingsFile(
}
}
if (
updates.provider !== undefined ||
updates.baseUrl !== undefined ||
updates.model !== undefined ||
settings.env?.CCS_DROID_PROVIDER
) {
settings.env = settings.env || {};
const resolvedProvider = resolveDroidProvider({
provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER,
baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL,
model: updates.model ?? settings.env.ANTHROPIC_MODEL,
});
settings.env.CCS_DROID_PROVIDER = resolvedProvider;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
@@ -32,6 +32,21 @@ describe('droid-config-manager', () => {
});
describe('upsertCcsModel', () => {
it('should return a selector reference for the managed model', async () => {
const ref = await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
});
expect(ref.profile).toBe('gemini');
expect(ref.selectorAlias).toBe('CCS-gemini-0');
expect(ref.selector).toBe('custom:CCS-gemini-0');
expect(ref.index).toBe(0);
});
it('should create settings.json with customModels', async () => {
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test';
import {
normalizeDroidProvider,
inferDroidProviderFromBaseUrl,
inferDroidProviderFromModel,
resolveDroidProvider,
} from '../../../src/targets/droid-provider';
describe('droid-provider', () => {
describe('normalizeDroidProvider', () => {
it('accepts canonical provider names', () => {
expect(normalizeDroidProvider('anthropic')).toBe('anthropic');
expect(normalizeDroidProvider('openai')).toBe('openai');
expect(normalizeDroidProvider('generic-chat-completion-api')).toBe(
'generic-chat-completion-api'
);
});
it('normalizes compatibility aliases', () => {
expect(normalizeDroidProvider('anthropic-compatible')).toBe('anthropic');
expect(normalizeDroidProvider('openai-compatible')).toBe('generic-chat-completion-api');
});
it('returns null for unknown values', () => {
expect(normalizeDroidProvider('')).toBeNull();
expect(normalizeDroidProvider('unsupported')).toBeNull();
expect(normalizeDroidProvider(undefined)).toBeNull();
});
});
describe('inferDroidProviderFromBaseUrl', () => {
it('detects anthropic-compatible endpoints', () => {
expect(inferDroidProviderFromBaseUrl('https://api.anthropic.com')).toBe('anthropic');
expect(inferDroidProviderFromBaseUrl('https://api.z.ai/api/anthropic')).toBe('anthropic');
});
it('detects openai official endpoints', () => {
expect(inferDroidProviderFromBaseUrl('https://api.openai.com/v1')).toBe('openai');
});
it('detects generic openai-chat-compatible endpoints', () => {
expect(inferDroidProviderFromBaseUrl('https://openrouter.ai/api/v1')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('https://api.deepinfra.com/v1/openai')).toBe(
'generic-chat-completion-api'
);
});
});
describe('inferDroidProviderFromModel', () => {
it('detects anthropic model naming', () => {
expect(inferDroidProviderFromModel('claude-sonnet-4-5-20250929')).toBe('anthropic');
});
it('detects openai model naming', () => {
expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai');
});
});
describe('resolveDroidProvider', () => {
it('prefers explicit provider', () => {
expect(
resolveDroidProvider({
provider: 'generic-chat-completion-api',
baseUrl: 'https://api.anthropic.com',
})
).toBe('generic-chat-completion-api');
});
it('falls back to URL inference when provider hint is missing', () => {
expect(resolveDroidProvider({ baseUrl: 'https://api.openai.com/v1' })).toBe('openai');
});
it('defaults to anthropic for legacy profiles without clear signal', () => {
expect(resolveDroidProvider({ baseUrl: 'http://127.0.0.1:8317' })).toBe('anthropic');
expect(resolveDroidProvider({})).toBe('anthropic');
});
});
});
+25 -1
View File
@@ -126,7 +126,8 @@ describe('DroidAdapter', () => {
});
it('should build args with -m custom:ccs- prefix', () => {
const args = adapter.buildArgs('gemini', ['--verbose']);
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
});
@@ -183,4 +184,27 @@ describe('DroidAdapter', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('buildArgs should use selector returned from Droid settings entry', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-'));
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
try {
const isolatedAdapter = new DroidAdapter();
await isolatedAdapter.prepareCredentials({
profile: 'gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
model: 'claude-sonnet-4-5-20250929',
});
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']);
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});