diff --git a/README.md b/README.md index 5aff1eb9..0245c4a9 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,10 @@ For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hin `anthropic`, `openai`, or `generic-chat-completion-api`. If the hint is missing, CCS resolves provider from base URL/model at runtime. +CCS also persists Droid's active model selector in `~/.factory/settings.json` +(`model: custom:`). This avoids passing `-m` argv in interactive mode, +which Droid treats as queued prompt text. + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 10141469..2deeb1d7 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:`. + * Writes credentials + active model to ~/.factory/settings.json and spawns `droid`. */ import { spawn, ChildProcess } from 'child_process'; @@ -19,7 +19,6 @@ 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()) { @@ -57,7 +56,9 @@ export class DroidAdapter implements TargetAdapter { apiKey: creds.apiKey, provider, }); - this.modelSelectorsByProfile.set(creds.profile, modelRef.selector); + if (!modelRef.selector) { + throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`); + } } buildArgs(profile: string, userArgs: string[]): string[] { @@ -66,8 +67,9 @@ export class DroidAdapter implements TargetAdapter { `Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed` ); } - const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`; - return ['-m', selector, ...userArgs]; + // Droid interactive mode treats unknown argv as queued prompt text. + // Model selection must be persisted in settings.json (`model`) instead of `-m`. + return [...userArgs]; } /** diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 1b48f1af..ac3c877a 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -52,6 +52,7 @@ export interface DroidManagedModelRef { } interface DroidSettings { + model?: string; customModels?: DroidCustomModelEntry[]; [key: string]: unknown; } @@ -346,19 +347,21 @@ export async function upsertCcsModel( settings.customModels.push(entry); } - writeDroidSettings(settings); - const index = settings.customModels.findIndex( (entry) => parseManagedProfile(entry.displayName) === profile ); const safeIndex = index >= 0 ? index : 0; const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex); + const selector = `custom:${selectorAlias}`; + // Droid interactive mode uses settings.model for default model selection. + settings.model = selector; + writeDroidSettings(settings); ref = { profile, displayName: entry.displayName, index: safeIndex, selectorAlias, - selector: `custom:${selectorAlias}`, + selector, }; } finally { if (release) await release(); diff --git a/src/targets/droid-provider.ts b/src/targets/droid-provider.ts index 3e6c4047..22a8d8c2 100644 --- a/src/targets/droid-provider.ts +++ b/src/targets/droid-provider.ts @@ -65,6 +65,8 @@ export function inferDroidProviderFromBaseUrl( const host = parsed.host.toLowerCase(); const pathname = parsed.pathname.toLowerCase(); + const isLocalHost = + host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]'); if ( host.includes('api.openai.com') || @@ -92,6 +94,11 @@ export function inferDroidProviderFromBaseUrl( return 'generic-chat-completion-api'; } + // Local OpenAI-compatible proxies are commonly exposed at /v1. + if (isLocalHost && (pathname === '/v1' || pathname.startsWith('/v1/'))) { + return 'generic-chat-completion-api'; + } + return null; } @@ -116,6 +123,13 @@ export function inferDroidProviderFromModel( ) { return 'openai'; } + if ( + normalized.startsWith('qwen') || + normalized.startsWith('deepseek') || + normalized.startsWith('kimi') + ) { + return 'generic-chat-completion-api'; + } return null; } diff --git a/tests/unit/targets/droid-adapter.test.ts b/tests/unit/targets/droid-adapter.test.ts index 3c1501c6..5d82e7e8 100644 --- a/tests/unit/targets/droid-adapter.test.ts +++ b/tests/unit/targets/droid-adapter.test.ts @@ -5,9 +5,9 @@ import { describe, it, expect } from 'bun:test'; import { DroidAdapter } from '../../../src/targets/droid-adapter'; describe('DroidAdapter.buildArgs', () => { - it('builds droid model args for valid profile names', () => { + it('passes user args without model injection for valid profile names', () => { const adapter = new DroidAdapter(); - expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['-m', 'custom:ccs-gemini_01', '--help']); + expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['--help']); }); it('rejects unsafe profile names', () => { diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 7b1f30b4..e96a76a5 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -45,6 +45,10 @@ describe('droid-config-manager', () => { expect(ref.selectorAlias).toBe('CCS-gemini-0'); expect(ref.selector).toBe('custom:CCS-gemini-0'); expect(ref.index).toBe(0); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.model).toBe('custom:CCS-gemini-0'); }); it('should create settings.json with customModels', async () => { @@ -63,6 +67,7 @@ describe('droid-config-manager', () => { expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('CCS gemini'); expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317'); + expect(settings.model).toBe('custom:CCS-gemini-0'); }); it('should update existing entry on second upsert', async () => { diff --git a/tests/unit/targets/droid-provider.test.ts b/tests/unit/targets/droid-provider.test.ts index 109764f0..2a9c5a3b 100644 --- a/tests/unit/targets/droid-provider.test.ts +++ b/tests/unit/targets/droid-provider.test.ts @@ -46,6 +46,18 @@ describe('droid-provider', () => { 'generic-chat-completion-api' ); }); + + it('detects localhost openai-compatible /v1 endpoints', () => { + expect(inferDroidProviderFromBaseUrl('http://127.0.0.1:1234/v1')).toBe( + 'generic-chat-completion-api' + ); + expect(inferDroidProviderFromBaseUrl('http://localhost:8317/v1/chat/completions')).toBe( + 'generic-chat-completion-api' + ); + expect(inferDroidProviderFromBaseUrl('http://[::1]:8317/v1')).toBe( + 'generic-chat-completion-api' + ); + }); }); describe('inferDroidProviderFromModel', () => { @@ -56,6 +68,12 @@ describe('droid-provider', () => { it('detects openai model naming', () => { expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai'); }); + + it('detects generic openai-compatible model families', () => { + expect(inferDroidProviderFromModel('qwen3-coder-plus')).toBe('generic-chat-completion-api'); + expect(inferDroidProviderFromModel('deepseek-v3.1')).toBe('generic-chat-completion-api'); + expect(inferDroidProviderFromModel('kimi-k2')).toBe('generic-chat-completion-api'); + }); }); describe('resolveDroidProvider', () => { diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index a289c150..c20b1bda 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -125,10 +125,16 @@ describe('DroidAdapter', () => { expect(adapter.supportsProfileType('copilot')).toBe(false); }); - it('should build args with -m custom:ccs- prefix', () => { + it('should keep interactive args clean (no model argv injection)', () => { const isolatedAdapter = new DroidAdapter(); const args = isolatedAdapter.buildArgs('gemini', ['--verbose']); - expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']); + expect(args).toEqual(['--verbose']); + }); + + it('should not queue model selector as prompt when no user args', () => { + const isolatedAdapter = new DroidAdapter(); + const args = isolatedAdapter.buildArgs('codex', []); + expect(args).toEqual([]); }); it('should build minimal env (no ANTHROPIC_ vars)', () => { @@ -200,7 +206,7 @@ describe('DroidAdapter', () => { }); const args = isolatedAdapter.buildArgs('gemini', ['--verbose']); - expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']); + expect(args).toEqual(['--verbose']); } finally { if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; else delete process.env.CCS_HOME;