From 191a026fec2acd1c2535d6b25a3d7e926f586fe5 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 12 Nov 2025 01:52:01 +0000 Subject: [PATCH] fix: handle Claude 4 Sonnet model identifier with cross-region inference - Fixed getModelById to use baseModelId when looking up unknown models in guessModelInfoFromId - Enhanced guessModelInfoFromId to recognize Claude 4 Sonnet and Claude 4.5 Sonnet patterns - Added comprehensive tests for Claude 4 Sonnet model handling with various configurations Fixes #9182 --- .../__tests__/bedrock-claude-4-sonnet.spec.ts | 223 ++++++++++++++++++ src/api/providers/bedrock.ts | 34 ++- 2 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 src/api/providers/__tests__/bedrock-claude-4-sonnet.spec.ts diff --git a/src/api/providers/__tests__/bedrock-claude-4-sonnet.spec.ts b/src/api/providers/__tests__/bedrock-claude-4-sonnet.spec.ts new file mode 100644 index 0000000000..413fb2967e --- /dev/null +++ b/src/api/providers/__tests__/bedrock-claude-4-sonnet.spec.ts @@ -0,0 +1,223 @@ +// npx vitest run src/api/providers/__tests__/bedrock-claude-4-sonnet.spec.ts + +import { AwsBedrockHandler } from "../bedrock" +import { describe, it, expect, vi } from "vitest" + +// Mock AWS SDK +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + return { + BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ + send: vi.fn(), + })), + ConverseStreamCommand: vi.fn(), + ConverseCommand: vi.fn(), + } +}) + +describe("Bedrock Claude 4 Sonnet Model Handling", () => { + // Helper function to create a handler with specific options + const createHandler = (overrides: any = {}) => { + const defaultOptions = { + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsAccessKey: "test-key", + awsSecretKey: "test-secret", + awsRegion: "us-east-1", + ...overrides, + } + return new AwsBedrockHandler(defaultOptions) + } + + describe("Claude 4 Sonnet Model Recognition", () => { + it("should correctly handle anthropic.claude-sonnet-4-20250514-v1:0 model", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + }) + + const model = handler.getModel() + expect(model.id).toBe("anthropic.claude-sonnet-4-20250514-v1:0") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + + it("should handle Claude 4 Sonnet with cross-region inference (US prefix)", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsUseCrossRegionInference: true, + awsRegion: "us-east-1", + }) + + const model = handler.getModel() + // Model ID should have the US prefix + expect(model.id).toBe("us.anthropic.claude-sonnet-4-20250514-v1:0") + // Model info should still be correctly resolved + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + + it("should handle Claude 4 Sonnet with cross-region inference (EU prefix)", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsUseCrossRegionInference: true, + awsRegion: "eu-west-1", + }) + + const model = handler.getModel() + // Model ID should have the EU prefix + expect(model.id).toBe("eu.anthropic.claude-sonnet-4-20250514-v1:0") + // Model info should still be correctly resolved + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + }) + + it("should handle Claude 4 Sonnet with cross-region inference (APAC prefix)", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsUseCrossRegionInference: true, + awsRegion: "ap-southeast-1", + }) + + const model = handler.getModel() + // Model ID should have the APAC prefix + expect(model.id).toBe("apac.anthropic.claude-sonnet-4-20250514-v1:0") + // Model info should still be correctly resolved + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + }) + + it("should handle Claude 4 Sonnet with global inference", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsUseGlobalInference: true, + awsRegion: "us-east-1", + }) + + const model = handler.getModel() + // Model ID should have the global prefix + expect(model.id).toBe("global.anthropic.claude-sonnet-4-20250514-v1:0") + // Model info should still be correctly resolved + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + }) + + it("should handle Claude 4 Sonnet with 1M context enabled", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsBedrock1MContext: true, + }) + + const model = handler.getModel() + expect(model.id).toBe("anthropic.claude-sonnet-4-20250514-v1:0") + // Context window should be updated to 1M + expect(model.info.contextWindow).toBe(1_000_000) + }) + + it("should handle Claude 4 Sonnet with cross-region inference and 1M context", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + awsUseCrossRegionInference: true, + awsRegion: "us-west-2", + awsBedrock1MContext: true, + }) + + const model = handler.getModel() + // Model ID should have the US prefix + expect(model.id).toBe("us.anthropic.claude-sonnet-4-20250514-v1:0") + // Context window should be 1M + expect(model.info.contextWindow).toBe(1_000_000) + // Other properties should be preserved + expect(model.info.maxTokens).toBe(8192) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + + it("should correctly parse base model ID from prefixed Claude 4 Sonnet", () => { + const handler = createHandler() + // Access private method through type casting + const parseBaseModelId = (handler as any).parseBaseModelId.bind(handler) + + // Test various prefixed versions + expect(parseBaseModelId("us.anthropic.claude-sonnet-4-20250514-v1:0")).toBe( + "anthropic.claude-sonnet-4-20250514-v1:0", + ) + expect(parseBaseModelId("eu.anthropic.claude-sonnet-4-20250514-v1:0")).toBe( + "anthropic.claude-sonnet-4-20250514-v1:0", + ) + expect(parseBaseModelId("apac.anthropic.claude-sonnet-4-20250514-v1:0")).toBe( + "anthropic.claude-sonnet-4-20250514-v1:0", + ) + expect(parseBaseModelId("global.anthropic.claude-sonnet-4-20250514-v1:0")).toBe( + "anthropic.claude-sonnet-4-20250514-v1:0", + ) + // Should not modify unprefixed model ID + expect(parseBaseModelId("anthropic.claude-sonnet-4-20250514-v1:0")).toBe( + "anthropic.claude-sonnet-4-20250514-v1:0", + ) + }) + + it("should handle getModelById for Claude 4 Sonnet with cross-region prefix", () => { + const handler = createHandler() + + // Test with US prefix + const modelWithUsPrefix = handler.getModelById("us.anthropic.claude-sonnet-4-20250514-v1:0") + expect(modelWithUsPrefix.id).toBe("anthropic.claude-sonnet-4-20250514-v1:0") + expect(modelWithUsPrefix.info.maxTokens).toBe(8192) + expect(modelWithUsPrefix.info.contextWindow).toBe(200_000) + expect(modelWithUsPrefix.info.supportsReasoningBudget).toBe(true) + + // Test with EU prefix + const modelWithEuPrefix = handler.getModelById("eu.anthropic.claude-sonnet-4-20250514-v1:0") + expect(modelWithEuPrefix.id).toBe("anthropic.claude-sonnet-4-20250514-v1:0") + expect(modelWithEuPrefix.info.maxTokens).toBe(8192) + expect(modelWithEuPrefix.info.contextWindow).toBe(200_000) + + // Test with global prefix + const modelWithGlobalPrefix = handler.getModelById("global.anthropic.claude-sonnet-4-20250514-v1:0") + expect(modelWithGlobalPrefix.id).toBe("anthropic.claude-sonnet-4-20250514-v1:0") + expect(modelWithGlobalPrefix.info.maxTokens).toBe(8192) + expect(modelWithGlobalPrefix.info.contextWindow).toBe(200_000) + }) + + it("should use guessModelInfoFromId for unknown Claude 4 variants", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-future-v1:0", // A hypothetical future model + }) + + const model = handler.getModel() + // Should still recognize it as a Claude Sonnet 4 model based on pattern + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + }) + + describe("Claude 4.5 Sonnet Model Recognition", () => { + it("should correctly handle anthropic.claude-sonnet-4-5-20250929-v1:0 model", () => { + const handler = createHandler({ + apiModelId: "anthropic.claude-sonnet-4-5-20250929-v1:0", + }) + + const model = handler.getModel() + expect(model.id).toBe("anthropic.claude-sonnet-4-5-20250929-v1:0") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + }) +}) diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index faaee0360f..2b3cf2e870 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -258,6 +258,20 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH private guessModelInfoFromId(modelId: string): Partial { // Define a mapping for model ID patterns and their configurations const modelConfigMap: Record> = { + "claude-sonnet-4-5": { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + }, + "claude-sonnet-4": { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + }, "claude-4": { maxTokens: 8192, contextWindow: 200_000, @@ -276,6 +290,13 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH supportsImages: true, supportsPromptCache: true, }, + "claude-opus-4": { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + }, "claude-4-opus": { maxTokens: 4096, contextWindow: 200_000, @@ -288,6 +309,13 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH supportsImages: true, supportsPromptCache: true, }, + "claude-haiku-4-5": { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + }, "claude-3-haiku": { maxTokens: 4096, contextWindow: 200_000, @@ -914,10 +942,10 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH info: JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultPromptRouterModelId])), } } else { - // Use heuristics for model info, then allow overrides from ProviderSettings - const guessed = this.guessModelInfoFromId(modelId) + // Use heuristics for model info based on the base model ID (without prefix) + const guessed = this.guessModelInfoFromId(baseModelId) model = { - id: bedrockDefaultModelId, + id: baseModelId, info: { ...JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultModelId])), ...guessed,