diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 7314a158..05635391 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -3486,8 +3486,13 @@ export class ClaudeAcpAgent { // configOptions, the current-model resolver, and the stored modelInfos // consistent with what the user configured. const settingsAvailableModels = settingsManager.getSettings().availableModels; + const settingsModelOverrides = settingsManager.getSettings().modelOverrides; const allowedModels = Array.isArray(settingsAvailableModels) - ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels) + ? applyAvailableModelsAllowlist( + initializationResult.models, + settingsAvailableModels, + settingsModelOverrides, + ) : initializationResult.models; const models = await getAvailableModels( @@ -4166,6 +4171,7 @@ function resolveSettingsModel( export function applyAvailableModelsAllowlist( sdkModels: ModelInfo[], allowlist: string[], + settingsModelOverrides?: Record, ): ModelInfo[] { // Default is always preserved per the docs. Synthesize one if the SDK // didn't surface it so downstream code (e.g. `getAvailableModels` picking @@ -4180,17 +4186,28 @@ export function applyAvailableModelsAllowlist( const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default"); + // Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6") + // in availableModels but require provider-specific IDs at the API. We still + // resolve `sdkMatch` against the alias (`trimmed`) — that's what the + // matching heuristics above are built for, and override targets (ARNs, + // opaque provider IDs) often won't textually resemble anything in + // `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the + // override target, so it's what `setModel` ends up passing to the API. for (const entry of allowlist) { const trimmed = entry.trim(); if (!trimmed || seen.has(trimmed)) continue; + const overridden = settingsModelOverrides?.[trimmed]; + const effective = overridden ?? trimmed; + if (seen.has(effective)) continue; + const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed); if (sdkMatch) { - result.push({ ...sdkMatch, value: trimmed }); + result.push({ ...sdkMatch, value: effective }); } else { - result.push({ value: trimmed, displayName: trimmed, description: "" }); + result.push({ value: effective, displayName: trimmed, description: "" }); } - seen.add(trimmed); + seen.add(effective); } // The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index d000dc59..dec54ba8 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -811,6 +811,55 @@ describe("ClaudeAcpAgent settings", () => { } } }); + + it("maps allowlist aliases through modelOverrides to provider IDs", async () => { + // Bedrock/Vertex deployments enforce short aliases in availableModels + // but require provider-specific IDs at the API. Without applying + // modelOverrides during allowlist resolution, the alias is surfaced as + // the model value and passed verbatim to setModel, which the provider + // API rejects with a 400 "invalid model identifier". + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-opus-4-6", "claude-sonnet-4-6"], + model: "claude-opus-4-6", + modelOverrides: { + "claude-opus-4-6": "global.anthropic.claude-opus-4-6-v1[1m]", + "claude-sonnet-4-6": "global.anthropic.claude-sonnet-4-6[1m]", + }, + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { setModelSpy } = mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "opus", displayName: "Opus", description: "Claude Opus 4.6" }, + { value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual([ + "default", + "global.anthropic.claude-opus-4-6-v1[1m]", + "global.anthropic.claude-sonnet-4-6[1m]", + ]); + // The allowlist synthesized the overridden ID (the SDK only surfaced the + // `opus` alias), so the adapter syncs the provider ID via setModel — never + // the raw alias that Bedrock would reject. + expect(setModelSpy).toHaveBeenCalledWith("global.anthropic.claude-opus-4-6-v1[1m]"); + expect(modelOption.currentValue).toBe("global.anthropic.claude-opus-4-6-v1[1m]"); + }); }); it("resolves model aliases like opus[1m] to the correct model", async () => { diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index 67530fd0..10a7137a 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -79,6 +79,38 @@ describe("applyAvailableModelsAllowlist - resolvedModel matching", () => { }); }); +describe("applyAvailableModelsAllowlist - modelOverrides", () => { + const SDK_MODELS: ModelInfo[] = [ + { value: "default", displayName: "Default", description: "Default model" }, + { + value: "opus", + displayName: "Opus", + description: "Claude Opus 4.6", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }, + ]; + + it("surfaces the override target as the value while keeping the alias's display metadata", () => { + // The override target is an opaque Bedrock ARN with no "opus" substring, + // so matching against it (instead of the "claude-opus-4-6" alias) would + // find nothing and silently drop displayName/description/supportsEffort. + const overrides = { + "claude-opus-4-6": "arn:aws:bedrock:us-west-2:111122223333:inference-profile/custom-7f3a", + }; + const result = applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides); + + const entry = result.find((m) => m.value === overrides["claude-opus-4-6"]); + expect(entry).toEqual({ + value: overrides["claude-opus-4-6"], + displayName: "Opus", + description: "Claude Opus 4.6", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }); + }); +}); + // Runs against the real SDK (no mocks) so the fixture above is flagged the // moment the SDK's actual model list shape drifts from it. Requires a usable // ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.