diff --git a/src/adapters/droid.ts b/src/adapters/droid.ts index d56a947..e3c3dec 100644 --- a/src/adapters/droid.ts +++ b/src/adapters/droid.ts @@ -6,18 +6,28 @@ import { getAdapter as getHarnessAdapter } from '@twaldin/harness-ts' const harness = getHarnessAdapter('factory-droid') const OAUTH_PROXY = 'http://127.0.0.1:10531/v1' -// droid stores per-session settings JSON. Default model is the user's -// factory.ai-billed Opus 4.7 (hits credit limit fast). To BYO model in -// interactive mode, write a settings file with the existing custom model -// id from ~/.factory/settings.json's `customModels` array — this routes -// through the local OAuth proxy and bypasses factory.ai billing. +// droid stores per-session settings JSON. The `model` field must be a +// `custom:` prefixed ID (e.g. "custom:gpt-5.4-0") matching an entry in +// ~/.factory/settings.json customModels — plain slugs trigger the factory.ai +// subscription gate. Each entry in customModels gets id="custom:{slug}-0" +// (droid auto-assigns the index per unique slug). The customModels entries +// point baseUrl at the local OAuth proxy so no OPENAI_BASE_URL is needed +// for routing, but we still set it as a fallback for non-session API calls. // -// Schema fields verified empirically from past session settings: -// model: id of the custom model (matches customModels[i].id) +// Schema fields verified from droid binary zod schema: +// model: customModels id (e.g. "custom:gpt-5.5-0") // autonomyLevel: 'off' | 'medium' | 'high' // autonomyMode: 'normal' | 'auto-medium' | 'auto-high' | 'spec' // reasoningEffort: 'low' | 'medium' | 'high' | 'none' // interactionMode: 'auto' | 'spec' +function toCustomModelId(slug: string): string { + // Already a custom: id — pass through unchanged. + if (slug.startsWith('custom:')) return slug + // droid generates id as `custom:${model.trim().replace(/\s+/g,"-")}-${n}` + // where n is the per-slug occurrence index (0 for unique slugs). + return `custom:${slug}-0` +} + function writeDroidSettings(workdir: string, modelId: string): string { const dir = join(workdir, '.flt', 'droid') mkdirSync(dir, { recursive: true }) @@ -39,10 +49,7 @@ export const droidAdapter: CliAdapter = { submitKeys: harness.submitKeys ?? ['Enter'], spawnArgs(opts: SpawnOpts): string[] { - // BYO model: use the existing customModels[0].id from ~/.factory/settings.json. - // Default custom id created by `droid` when adding the OAuth proxy is - // "custom:gpt-5.4-(codex-oauth-proxy)-0". User can override via opts.model. - const modelId = opts.model ?? 'custom:gpt-5.4-(codex-oauth-proxy)-0' + const modelId = toCustomModelId(opts.model ?? 'gpt-5.4') const settings = writeDroidSettings(opts.dir, modelId) return ['droid', '--settings', settings] }, diff --git a/src/model-resolution.ts b/src/model-resolution.ts index de3bbc5..01558e3 100644 --- a/src/model-resolution.ts +++ b/src/model-resolution.ts @@ -9,23 +9,28 @@ const ALIAS_TABLE: Record> = { 'cc-opus': { 'claude-code': 'opus[1m]', 'codex': 'gpt-5.4', + 'droid': 'gpt-5.5', 'openclaude': 'opus[1m]', }, 'cc-sonnet': { 'claude-code': 'sonnet', 'crush': 'anthropic/claude-sonnet-4-6', + 'droid': 'gpt-5.4', 'openclaude': 'sonnet', }, 'cc-haiku': { 'claude-code': 'haiku', + 'droid': 'gpt-5.4-mini', 'openclaude': 'haiku', }, 'pi-coder': { 'codex': 'gpt-5.3-codex', + 'droid': 'gpt-5.3-codex', 'pi': 'openai-codex/gpt-5.3-codex', }, 'pi-deep': { 'codex': 'gpt-5.4-high', + 'droid': 'gpt-5.5', 'pi': 'openai-codex/gpt-5.4:high', }, 'gemini-pro': { @@ -64,6 +69,7 @@ const BARE_MODEL_HARNESSES = new Set([ 'claude-code', 'codex', 'continue-cli', + 'droid', 'gemini', 'openclaude', 'qwen', @@ -152,9 +158,6 @@ export function resolveModelForCli(cli: string, model: string | undefined, noRes } else { resolved = normalized } - } else if (cli === 'factory-droid') { - const bare = stripKnownProviderPrefixes(normalized) - resolved = bare.startsWith('custom:') ? bare : `custom:${bare}` } else if (PROVIDER_MODEL_HARNESSES.has(cli)) { resolved = ensureProviderPrefix(normalized) } else if (PRESERVE_EXPLICIT_PROVIDER_HARNESSES.has(cli) && normalized.includes('/')) { diff --git a/tests/unit/model-resolution.test.ts b/tests/unit/model-resolution.test.ts index 0f585c0..7ea8c09 100644 --- a/tests/unit/model-resolution.test.ts +++ b/tests/unit/model-resolution.test.ts @@ -14,6 +14,8 @@ describe('model resolution', () => { it('strips provider for bare-model CLIs', () => { expect(resolveModelForCli('codex', 'openai-codex/gpt-5.4')).toBe('gpt-5.4') expect(resolveModelForCli('claude-code', 'anthropic/sonnet')).toBe('sonnet') + expect(resolveModelForCli('droid', 'openai/gpt-5.4')).toBe('gpt-5.4') + expect(resolveModelForCli('droid', 'gpt-5.5')).toBe('gpt-5.5') }) it('adds provider for provider-model CLIs', () => { @@ -33,9 +35,10 @@ describe('resolveAlias', () => { expect(resolveAlias('pi', 'some-random-alias')).toBeNull() }) - it('cc-opus: maps correctly for claude-code, codex, openclaude', () => { + it('cc-opus: maps correctly for claude-code, codex, droid, openclaude', () => { expect(resolveAlias('claude-code', 'cc-opus')).toBe('opus[1m]') expect(resolveAlias('codex', 'cc-opus')).toBe('gpt-5.4') + expect(resolveAlias('droid', 'cc-opus')).toBe('gpt-5.5') expect(resolveAlias('openclaude', 'cc-opus')).toBe('opus[1m]') }) @@ -45,7 +48,6 @@ describe('resolveAlias', () => { expect(() => resolveAlias('opencode', 'cc-opus')).toThrow('Model alias "cc-opus" has no mapping for CLI "opencode".') expect(() => resolveAlias('crush', 'cc-opus')).toThrow('Model alias "cc-opus" has no mapping for CLI "crush".') expect(() => resolveAlias('continue-cli', 'cc-opus')).toThrow('Model alias "cc-opus" has no mapping for CLI "continue-cli".') - expect(() => resolveAlias('droid', 'cc-opus')).toThrow('Model alias "cc-opus" has no mapping for CLI "droid".') expect(() => resolveAlias('qwen', 'cc-opus')).toThrow('Model alias "cc-opus" has no mapping for CLI "qwen".') expect(() => resolveAlias('kilo', 'cc-opus')).toThrow('Model alias "cc-opus" has no mapping for CLI "kilo".') }) @@ -56,6 +58,10 @@ describe('resolveAlias', () => { expect(resolveAlias('openclaude', 'cc-sonnet')).toBe('sonnet') }) + it('cc-sonnet: maps correctly for droid', () => { + expect(resolveAlias('droid', 'cc-sonnet')).toBe('gpt-5.4') + }) + it('cc-sonnet: throws for CLIs with no mapping', () => { expect(() => resolveAlias('codex', 'cc-sonnet')).toThrow('Model alias "cc-sonnet" has no mapping for CLI "codex".') expect(() => resolveAlias('pi', 'cc-sonnet')).toThrow('Model alias "cc-sonnet" has no mapping for CLI "pi".') @@ -70,14 +76,19 @@ describe('resolveAlias', () => { expect(resolveAlias('openclaude', 'cc-haiku')).toBe('haiku') }) + it('cc-haiku: maps correctly for droid', () => { + expect(resolveAlias('droid', 'cc-haiku')).toBe('gpt-5.4-mini') + }) + it('cc-haiku: throws for CLIs with no mapping', () => { expect(() => resolveAlias('codex', 'cc-haiku')).toThrow('Model alias "cc-haiku" has no mapping for CLI "codex".') expect(() => resolveAlias('pi', 'cc-haiku')).toThrow('Model alias "cc-haiku" has no mapping for CLI "pi".') expect(() => resolveAlias('gemini', 'cc-haiku')).toThrow('Model alias "cc-haiku" has no mapping for CLI "gemini".') }) - it('pi-coder: maps correctly for codex and pi', () => { + it('pi-coder: maps correctly for codex, droid, and pi', () => { expect(resolveAlias('codex', 'pi-coder')).toBe('gpt-5.3-codex') + expect(resolveAlias('droid', 'pi-coder')).toBe('gpt-5.3-codex') expect(resolveAlias('pi', 'pi-coder')).toBe('openai-codex/gpt-5.3-codex') }) @@ -87,14 +98,14 @@ describe('resolveAlias', () => { expect(() => resolveAlias('opencode', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "opencode".') expect(() => resolveAlias('crush', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "crush".') expect(() => resolveAlias('continue-cli', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "continue-cli".') - expect(() => resolveAlias('droid', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "droid".') expect(() => resolveAlias('openclaude', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "openclaude".') expect(() => resolveAlias('qwen', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "qwen".') expect(() => resolveAlias('kilo', 'pi-coder')).toThrow('Model alias "pi-coder" has no mapping for CLI "kilo".') }) - it('pi-deep: maps correctly for codex and pi', () => { + it('pi-deep: maps correctly for codex, droid, and pi', () => { expect(resolveAlias('codex', 'pi-deep')).toBe('gpt-5.4-high') + expect(resolveAlias('droid', 'pi-deep')).toBe('gpt-5.5') expect(resolveAlias('pi', 'pi-deep')).toBe('openai-codex/gpt-5.4:high') })