From e99768a04afaa299e7fc818acf4753a8434a77cb Mon Sep 17 00:00:00 2001 From: twaldin Date: Wed, 6 May 2026 16:42:20 -0700 Subject: [PATCH 1/2] fix(droid): route through OAuth proxy with direct model slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The droid adapter used 'custom:gpt-5.4-(codex-oauth-proxy)-0' as its default model ID — an old format generated by droid's interactive UI that requires a matching customModels entry in ~/.factory/settings.json. No such entry exists, causing droid to fall back to factory.ai's billed Opus 4.7 model instead of routing through the local OAuth proxy. Fix: use direct model slugs (e.g. 'gpt-5.4'). Droid honors OPENAI_BASE_URL for routing when given a plain slug, and the adapter already injects OPENAI_BASE_URL=http://127.0.0.1:10531/v1 + OPENAI_API_KEY=unused. Verified by the running data-detective session which uses 'gpt-5.5' this way. Also: - Add 'droid' to BARE_MODEL_HARNESSES (strips openai/ prefix) - Add droid entries to all cross-CLI aliases (cc-opus→gpt-5.5, cc-sonnet→gpt-5.4, cc-haiku→gpt-5.4-mini, pi-coder→gpt-5.3-codex, pi-deep→gpt-5.5) - Remove dead 'factory-droid' model resolution path (adapter is registered as 'droid'; the factory-droid custom:-prefix logic was never reachable) - Update tests to cover new droid alias mappings Co-Authored-By: Claude Sonnet 4.6 --- src/adapters/droid.ts | 16 ++++++---------- src/model-resolution.ts | 9 ++++++--- tests/unit/model-resolution.test.ts | 21 ++++++++++++++++----- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/adapters/droid.ts b/src/adapters/droid.ts index d56a947..5c97f21 100644 --- a/src/adapters/droid.ts +++ b/src/adapters/droid.ts @@ -6,14 +6,13 @@ 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 is a direct +// model slug (e.g. "gpt-5.4") — NOT a customModels ID. Droid routes +// requests through OPENAI_BASE_URL (the local OAuth proxy) when that env +// var is set, so no customModels entry is needed for flt-spawned agents. // // Schema fields verified empirically from past session settings: -// model: id of the custom model (matches customModels[i].id) +// model: model slug (e.g. "gpt-5.5", "gpt-5.4", "gpt-5.4-mini") // autonomyLevel: 'off' | 'medium' | 'high' // autonomyMode: 'normal' | 'auto-medium' | 'auto-high' | 'spec' // reasoningEffort: 'low' | 'medium' | 'high' | 'none' @@ -39,10 +38,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 = 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') }) From 933199a377a532fac0b63a8ef46a63be910aa7fb Mon Sep 17 00:00:00 2001 From: twaldin Date: Wed, 6 May 2026 17:00:45 -0700 Subject: [PATCH 2/2] fix(droid): use custom: model IDs to bypass factory.ai subscription gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain model slugs (gpt-5.5, etc.) cause droid to treat the model as factory-hosted and fail with "No active subscription found". Custom model entries in ~/.factory/settings.json get `custom:{slug}-0` IDs which signal BYO-provider mode and skip the subscription check entirely. - Add toCustomModelId() helper: converts slug → custom:{slug}-0 (droid's auto-assigned ID format for unique per-slug entries) - Session settings now write custom: prefixed IDs so droid routes through the customModels entry's baseUrl (the OAuth proxy) instead of factory.ai Note: ~/.factory/settings.json customModels must be populated with the GPT subscription models pointing at the OAuth proxy (done separately). Co-Authored-By: Claude Sonnet 4.6 --- src/adapters/droid.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/adapters/droid.ts b/src/adapters/droid.ts index 5c97f21..e3c3dec 100644 --- a/src/adapters/droid.ts +++ b/src/adapters/droid.ts @@ -6,17 +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. The `model` field is a direct -// model slug (e.g. "gpt-5.4") — NOT a customModels ID. Droid routes -// requests through OPENAI_BASE_URL (the local OAuth proxy) when that env -// var is set, so no customModels entry is needed for flt-spawned agents. +// 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: model slug (e.g. "gpt-5.5", "gpt-5.4", "gpt-5.4-mini") +// 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 }) @@ -38,7 +49,7 @@ export const droidAdapter: CliAdapter = { submitKeys: harness.submitKeys ?? ['Enter'], spawnArgs(opts: SpawnOpts): string[] { - const modelId = opts.model ?? 'gpt-5.4' + const modelId = toCustomModelId(opts.model ?? 'gpt-5.4') const settings = writeDroidSettings(opts.dir, modelId) return ['droid', '--settings', settings] },