diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index c79ab55b2..60cca2663 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -222,9 +222,30 @@ export function resolveModelId( ); } - // 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]; } diff --git a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts index 2bc41fa9e..4ea3245a3 100644 --- a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +++ b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts @@ -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"); +});