fix: resolve bare model IDs to anthropic over claude-code provider (#3076)

When the claude-code-cli extension is active and the session provider is
claude-code, bare model IDs in PREFERENCES.md (e.g. "claude-sonnet-4-6")
silently resolve to claude-code/* instead of anthropic/*, routing all
dispatch through the Claude Code CLI subprocess with different context,
tool visibility, and cost characteristics.

The fix introduces provider precedence in resolveModelId(): extension
providers like claude-code are deprioritized for bare ID resolution.
First-class API providers (anthropic, bedrock, openai, azure, etc.)
retain the existing current-provider preference behavior. When the
session provider is an extension, resolution falls through to prefer
anthropic, then any non-extension provider, preserving backward
compatibility for all existing provider combinations.

Closes #2905

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:40:26 -04:00 committed by GitHub
parent e7351fbd75
commit a3fc400c09
2 changed files with 97 additions and 6 deletions

View file

@ -222,9 +222,30 @@ export function resolveModelId<T extends { id: string; provider: string }>(
);
}
// Bare ID — prefer current provider, then first available
const exactProviderMatch = availableModels.find(
m => m.id === modelId && m.provider === currentProvider,
);
return exactProviderMatch ?? availableModels.find(m => m.id === modelId);
// Bare ID — resolve with provider precedence to avoid silent misrouting.
// Extension providers (e.g. claude-code) expose the same model IDs as their
// upstream API providers but route through a subprocess with different
// context, tool visibility, and cost characteristics (#2905). Bare IDs in
// PREFERENCES.md must resolve to the canonical API provider, not to an
// extension wrapper that happens to be the current session provider.
const candidates = availableModels.filter(m => m.id === modelId);
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
// Extension / CLI-wrapper providers that should never win bare-ID resolution
// when a first-class API provider also offers the same model.
const EXTENSION_PROVIDERS = new Set(["claude-code"]);
// Prefer currentProvider only when it is a first-class API provider
if (currentProvider && !EXTENSION_PROVIDERS.has(currentProvider)) {
const providerMatch = candidates.find(m => m.provider === currentProvider);
if (providerMatch) return providerMatch;
}
// Prefer "anthropic" as the canonical provider for Anthropic models
const anthropicMatch = candidates.find(m => m.provider === "anthropic");
if (anthropicMatch) return anthropicMatch;
// Fall back to first non-extension candidate, or any candidate
return candidates.find(m => !EXTENSION_PROVIDERS.has(m.provider)) ?? candidates[0];
}

View file

@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolvePreferredModelConfig } from "../auto-model-selection.js";
import { resolvePreferredModelConfig, resolveModelId } from "../auto-model-selection.js";
function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
@ -137,3 +137,73 @@ test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", (
rmSync(tempGsdHome, { recursive: true, force: true });
}
});
// ─── resolveModelId tests ─────────────────────────────────────────────────
test("resolveModelId: bare ID resolves to anthropic over claude-code when session is claude-code (#2905)", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "anthropic" },
{ id: "claude-sonnet-4-6", provider: "claude-code" },
];
// Bug: when currentProvider is "claude-code", bare ID "claude-sonnet-4-6"
// resolves to claude-code/claude-sonnet-4-6 instead of anthropic/claude-sonnet-4-6
const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "anthropic", "bare ID must resolve to anthropic, not claude-code");
});
test("resolveModelId: bare ID still prefers current provider when it is a first-class API provider", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "anthropic" },
{ id: "claude-sonnet-4-6", provider: "bedrock" },
];
const result = resolveModelId("claude-sonnet-4-6", availableModels, "bedrock");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "bedrock", "bare ID should prefer current provider when it is a real API provider");
});
test("resolveModelId: explicit provider/model format still resolves to claude-code when specified", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "anthropic" },
{ id: "claude-sonnet-4-6", provider: "claude-code" },
];
const result = resolveModelId("claude-code/claude-sonnet-4-6", availableModels, "anthropic");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "claude-code", "explicit provider prefix must be respected");
});
test("resolveModelId: bare ID with only one provider works normally", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "anthropic" },
];
const result = resolveModelId("claude-sonnet-4-6", availableModels, "anthropic");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "anthropic");
});
test("resolveModelId: bare ID with claude-code as only provider still resolves", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "claude-code" },
];
// If claude-code is the ONLY provider for this model, it should still resolve
const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
assert.ok(result, "should resolve even when only available via claude-code");
assert.equal(result.provider, "claude-code");
});
test("resolveModelId: anthropic wins over claude-code regardless of list order", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "claude-code" },
{ id: "claude-sonnet-4-6", provider: "anthropic" },
];
// Even when claude-code appears first in the list, anthropic should win
const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "anthropic", "anthropic must win over claude-code regardless of list order");
});