diff --git a/README.md b/README.md index 3d1fda3a..5aff1eb9 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index f90455c2..6e21c2e4 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -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); } diff --git a/src/ccs.ts b/src/ccs.ts index 94e096e6..245d4bf9 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 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 { 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 { 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( diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 1a89ca20..10141469 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -2,7 +2,7 @@ * Droid Adapter * * TargetAdapter implementation for Factory Droid CLI. - * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-`. + * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:`. */ 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(); private validateCredentials(creds: TargetCredentials): void { if (!creds.baseUrl?.trim()) { @@ -43,13 +45,19 @@ export class DroidAdapter implements TargetAdapter { */ async prepareCredentials(creds: TargetCredentials): Promise { 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]; } /** diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 8e61eec4..1b48f1af 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -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 { +export async function upsertCcsModel( + profile: string, + model: DroidCustomModel +): Promise { validateProfileName(profile); ensureFactoryDir(); let release: (() => Promise) | 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`, + } + ); } /** diff --git a/src/targets/droid-provider.ts b/src/targets/droid-provider.ts new file mode 100644 index 00000000..3e6c4047 --- /dev/null +++ b/src/targets/droid-provider.ts @@ -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([ + 'generic', + 'generic-openai', + 'generic-openai-api', + 'generic-chat', + 'generic-chat-completions', + 'openai-compatible', + 'chat-completions', +]); + +const OPENAI_PROVIDER_ALIASES = new Set(['openai-responses', 'openai-official']); +const ANTHROPIC_PROVIDER_ALIASES = new Set(['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'; +} diff --git a/src/targets/index.ts b/src/targets/index.ts index a0c42429..2d05d240 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -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'; diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 1b1a209f..eb176802 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -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) { diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 076a12d7..24b54245 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -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'); } diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index dd1c4675..7b1f30b4 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -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', diff --git a/tests/unit/targets/droid-provider.test.ts b/tests/unit/targets/droid-provider.test.ts new file mode 100644 index 00000000..109764f0 --- /dev/null +++ b/tests/unit/targets/droid-provider.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 2a7e7924..a289c150 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -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 }); + } + }); });