From 7364696ae6f26d365a6ddef2e51c613425c79999 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 22 Feb 2026 14:40:36 +0100 Subject: [PATCH] fix(coding-agent): prefer provider/model split over gateway model id matching When resolving --model zai/glm-5, the resolver now correctly interprets 'zai' as the provider and 'glm-5' as the model id, rather than matching a vercel-ai-gateway model whose id is literally 'zai/glm-5'. If the provider/model split fails to find a match, falls back to raw id matching to still support OpenRouter-style ids like 'openai/gpt-4o:extended'. --- .../coding-agent/src/core/model-resolver.ts | 84 +++++++++++++------ .../coding-agent/test/model-resolver.test.ts | 41 +++++++++ 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index bd74c206..c8baf3a3 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -311,8 +311,29 @@ export function resolveCliModel(options: { }; } - // If no explicit --provider, first try exact matches without any provider inference. - // This avoids misinterpreting model IDs that themselves contain slashes (e.g. OpenRouter-style IDs). + // If no explicit --provider, try to interpret "provider/model" format first. + // When the prefix before the first slash matches a known provider, prefer that + // interpretation over matching models whose IDs literally contain slashes + // (e.g. "zai/glm-5" should resolve to provider=zai, model=glm-5, not to a + // vercel-ai-gateway model with id "zai/glm-5"). + let pattern = cliModel; + let inferredProvider = false; + + if (!provider) { + const slashIndex = cliModel.indexOf("/"); + if (slashIndex !== -1) { + const maybeProvider = cliModel.substring(0, slashIndex); + const canonical = providerMap.get(maybeProvider.toLowerCase()); + if (canonical) { + provider = canonical; + pattern = cliModel.substring(slashIndex + 1); + inferredProvider = true; + } + } + } + + // If no provider was inferred from the slash, try exact matches without provider inference. + // This handles models whose IDs naturally contain slashes (e.g. OpenRouter-style IDs). if (!provider) { const lower = cliModel.toLowerCase(); const exact = availableModels.find( @@ -323,20 +344,7 @@ export function resolveCliModel(options: { } } - let pattern = cliModel; - - // If no explicit --provider, allow --model provider/ - if (!provider) { - const slashIndex = cliModel.indexOf("/"); - if (slashIndex !== -1) { - const maybeProvider = cliModel.substring(0, slashIndex); - const canonical = providerMap.get(maybeProvider.toLowerCase()); - if (canonical) { - provider = canonical; - pattern = cliModel.substring(slashIndex + 1); - } - } - } else { + if (cliProvider && provider) { // If both were provided, tolerate --model / by stripping the provider prefix const prefix = `${provider}/`; if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) { @@ -349,17 +357,43 @@ export function resolveCliModel(options: { allowInvalidThinkingLevelFallback: false, }); - if (!model) { - const display = provider ? `${provider}/${pattern}` : cliModel; - return { - model: undefined, - thinkingLevel: undefined, - warning, - error: `Model "${display}" not found. Use --list-models to see available models.`, - }; + if (model) { + return { model, thinkingLevel, warning, error: undefined }; } - return { model, thinkingLevel, warning, error: undefined }; + // If we inferred a provider from the slash but found no match within that provider, + // fall back to matching the full input as a raw model id across all models. + // This handles OpenRouter-style IDs like "openai/gpt-4o:extended" where "openai" + // looks like a provider but the full string is actually a model id on openrouter. + if (inferredProvider) { + const lower = cliModel.toLowerCase(); + const exact = availableModels.find( + (m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower, + ); + if (exact) { + return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined }; + } + // Also try parseModelPattern on the full input against all models + const fallback = parseModelPattern(cliModel, availableModels, { + allowInvalidThinkingLevelFallback: false, + }); + if (fallback.model) { + return { + model: fallback.model, + thinkingLevel: fallback.thinkingLevel, + warning: fallback.warning, + error: undefined, + }; + } + } + + const display = provider ? `${provider}/${pattern}` : cliModel; + return { + model: undefined, + thinkingLevel: undefined, + warning, + error: `Model "${display}" not found. Use --list-models to see available models.`, + }; } export interface InitialModelResult { diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index c14d524b..38aa73c2 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -298,6 +298,47 @@ describe("resolveCliModel", () => { expect(result.error).toContain("No models available"); }); + test("prefers provider/model split over gateway model with matching id", () => { + // When a user writes "zai/glm-5", and both a zai provider model (id: "glm-5") + // and a gateway model (id: "zai/glm-5") exist, prefer the zai provider model. + const zaiModel: Model<"anthropic-messages"> = { + id: "glm-5", + name: "GLM-5", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + reasoning: true, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const gatewayModel: Model<"anthropic-messages"> = { + id: "zai/glm-5", + name: "GLM-5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const registry = { + getAll: () => [...allModels, zaiModel, gatewayModel], + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "zai/glm-5", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("zai"); + expect(result.model?.id).toBe("glm-5"); + }); + test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => { const registry = { getAll: () => allModels,