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'.
This commit is contained in:
Mario Zechner 2026-02-22 14:40:36 +01:00
parent de1560a7ba
commit 7364696ae6
2 changed files with 100 additions and 25 deletions

View file

@ -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/<pattern>
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 <provider>/<pattern> 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 {

View file

@ -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<typeof resolveCliModel>[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,