Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions src/adapters/droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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]
},
Expand Down
9 changes: 6 additions & 3 deletions src/model-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,28 @@ const ALIAS_TABLE: Record<string, Record<string, string>> = {
'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': {
Expand Down Expand Up @@ -64,6 +69,7 @@ const BARE_MODEL_HARNESSES = new Set([
'claude-code',
'codex',
'continue-cli',
'droid',
'gemini',
'openclaude',
'qwen',
Expand Down Expand Up @@ -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('/')) {
Expand Down
21 changes: 16 additions & 5 deletions tests/unit/model-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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]')
})

Expand All @@ -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".')
})
Expand All @@ -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".')
Expand All @@ -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')
})

Expand All @@ -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')
})

Expand Down