From 6f4bd814b8b36cb77ed4d4c3d42c844800376484 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 4 Mar 2026 00:20:49 +1100 Subject: [PATCH] fix(coding-agent): allow provider-scoped custom model ids (#1759) --- .../coding-agent/src/core/model-resolver.ts | 40 +++++++++++++++++-- .../coding-agent/test/model-resolver.test.ts | 40 +++++++++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index c8baf3a3..6c103edc 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -113,6 +113,22 @@ export interface ParsedModelResult { warning: string | undefined; } +function buildFallbackModel(provider: string, modelId: string, availableModels: Model[]): Model | undefined { + const providerModels = availableModels.filter((m) => m.provider === provider); + if (providerModels.length === 0) return undefined; + + const defaultId = defaultModelPerProvider[provider as KnownProvider]; + const baseModel = defaultId + ? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0]) + : providerModels[0]; + + return { + ...baseModel, + id: modelId, + name: modelId, + }; +} + /** * Parse a pattern to extract model and thinking level. * Handles models with colons in their IDs (e.g., OpenRouter's :exacto suffix). @@ -387,6 +403,16 @@ export function resolveCliModel(options: { } } + if (provider) { + const fallbackModel = buildFallbackModel(provider, pattern, availableModels); + if (fallbackModel) { + const fallbackWarning = warning + ? `${warning} Model "${pattern}" not found for provider "${provider}". Using custom model id.` + : `Model "${pattern}" not found for provider "${provider}". Using custom model id.`; + return { model: fallbackModel, thinkingLevel: undefined, warning: fallbackWarning, error: undefined }; + } + } + const display = provider ? `${provider}/${pattern}` : cliModel; return { model: undefined, @@ -436,12 +462,18 @@ export async function findInitialModel(options: { // 1. CLI args take priority if (cliProvider && cliModel) { - const found = modelRegistry.find(cliProvider, cliModel); - if (!found) { - console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`)); + const resolved = resolveCliModel({ + cliProvider, + cliModel, + modelRegistry, + }); + if (resolved.error) { + console.error(chalk.red(resolved.error)); process.exit(1); } - return { model: found, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; + if (resolved.model) { + return { model: resolved.model, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; + } } // 2. Use first model from scoped models (skip if continuing/resuming) diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 38aa73c2..3f3ab977 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -268,7 +268,7 @@ describe("resolveCliModel", () => { expect(result.model?.id).toBe("openai/gpt-4o:extended"); }); - test("does not strip invalid :suffix as thinking level in --model (fail fast)", () => { + test("does not strip invalid :suffix as thinking level in --model (treat as raw id)", () => { const registry = { getAll: () => allModels, } as unknown as Parameters[0]["modelRegistry"]; @@ -279,8 +279,25 @@ describe("resolveCliModel", () => { modelRegistry: registry, }); - expect(result.model).toBeUndefined(); - expect(result.error).toContain("not found"); + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("openai"); + expect(result.model?.id).toBe("gpt-4o:extended"); + }); + + test("allows custom model ids for explicit providers without double prefixing", () => { + const registry = { + getAll: () => allModels, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliProvider: "openrouter", + cliModel: "openrouter/openai/ghost-model", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("openrouter"); + expect(result.model?.id).toBe("openai/ghost-model"); }); test("returns a clear error when there are no models", () => { @@ -360,6 +377,23 @@ describe("default model selection", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("anthropic/claude-opus-4-6"); }); + test("findInitialModel accepts explicit provider custom model ids", async () => { + const registry = { + getAll: () => allModels, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = await findInitialModel({ + cliProvider: "openrouter", + cliModel: "openrouter/openai/ghost-model", + scopedModels: [], + isContinuing: false, + modelRegistry: registry, + }); + + expect(result.model?.provider).toBe("openrouter"); + expect(result.model?.id).toBe("openai/ghost-model"); + }); + test("findInitialModel selects ai-gateway default when available", async () => { const aiGatewayModel: Model<"anthropic-messages"> = { id: "anthropic/claude-opus-4-6",