diff --git a/src/LLMProviders/BedrockChatModel.test.ts b/src/LLMProviders/BedrockChatModel.test.ts index dc1f6fb4..aae8dbae 100644 --- a/src/LLMProviders/BedrockChatModel.test.ts +++ b/src/LLMProviders/BedrockChatModel.test.ts @@ -89,6 +89,24 @@ const createModel = (enableThinking = false): BedrockChatModel => fetchImplementation: jest.fn(), }); +const createModelWithFetch = ( + fetchMock: jest.Mock, + opts?: { modelId?: string; noStream?: boolean } +): BedrockChatModel => + new BedrockChatModel({ + modelId: opts?.modelId ?? "anthropic.claude-sonnet-4-5-20250929-v1:0", + apiKey: "test-key", + endpoint: "https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke", + ...(opts?.noStream + ? {} + : { + streamEndpoint: + "https://example.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke-with-response-stream", + }), + anthropicVersion: "bedrock-2023-05-31", + fetchImplementation: fetchMock, + }); + describe("BedrockChatModel streaming decode", () => { it("decodes simple base64 JSON payloads", () => { const payload = JSON.stringify({ @@ -657,3 +675,94 @@ describe("BedrockChatModel streaming decode", () => { }); }); }); + +describe("BedrockChatModel inference-profile error rewriting", () => { + const awsInferenceProfileError = JSON.stringify({ + message: + "Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.", + }); + + const makeErrorResponse = (status: number, body: string): Response => + ({ + ok: false, + status, + text: () => Promise.resolve(body), + }) as unknown as Response; + + it("rewrites 400 inference-profile error in non-streaming path to actionable message", async () => { + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError)); + const model = createModelWithFetch(fetchMock, { noStream: true }); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + await expect(model._generate(messages as never, {})).rejects.toThrow( + /cross-region inference profile ID/ + ); + }); + + it("rewrites 400 inference-profile error in streaming path to actionable message", async () => { + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError)); + const model = createModelWithFetch(fetchMock); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + const gen = model._streamResponseChunks(messages as never, {}); + await expect(gen.next()).rejects.toThrow(/cross-region inference profile ID/); + }); + + it("includes the bare model ID in the rewritten message", async () => { + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, awsInferenceProfileError)); + const model = createModelWithFetch(fetchMock, { noStream: true }); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + await expect(model._generate(messages as never, {})).rejects.toThrow( + /anthropic\.claude-sonnet-4-5/ + ); + }); + + it("does not rewrite a 400 error that is unrelated to inference profiles", async () => { + const genericBody = JSON.stringify({ message: "ValidationException: bad request" }); + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, genericBody)); + const model = createModelWithFetch(fetchMock, { noStream: true }); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + await expect(model._generate(messages as never, {})).rejects.toThrow( + /Amazon Bedrock request failed with status 400/ + ); + }); + + it("does not rewrite non-400 errors", async () => { + const body = JSON.stringify({ message: "Internal Server Error" }); + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(500, body)); + const model = createModelWithFetch(fetchMock, { noStream: true }); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + await expect(model._generate(messages as never, {})).rejects.toThrow( + /Amazon Bedrock request failed with status 500/ + ); + }); + + it("rewrites the error even when AWS uses a curly apostrophe in 'isn’t supported'", async () => { + const curlyApostropheBody = JSON.stringify({ + message: + "Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isn’t supported. Retry your request with the ID or ARN of an inference profile that contains this model.", + }); + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, curlyApostropheBody)); + const model = createModelWithFetch(fetchMock, { noStream: true }); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + await expect(model._generate(messages as never, {})).rejects.toThrow( + /cross-region inference profile ID/ + ); + }); + + it("uses the provider segment from the bare model ID in the prefix guidance", async () => { + const nonAnthropicBody = JSON.stringify({ + message: + "Invocation of model ID meta.llama4-maverick-17b with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model.", + }); + const fetchMock = jest.fn().mockResolvedValue(makeErrorResponse(400, nonAnthropicBody)); + const model = createModelWithFetch(fetchMock, { noStream: true }); + + const messages = [{ content: "hi", getType: () => "human", type: "human" }]; + await expect(model._generate(messages as never, {})).rejects.toThrow(/global\.meta\./); + }); +}); diff --git a/src/LLMProviders/BedrockChatModel.ts b/src/LLMProviders/BedrockChatModel.ts index 3464e25d..8eca6ae5 100644 --- a/src/LLMProviders/BedrockChatModel.ts +++ b/src/LLMProviders/BedrockChatModel.ts @@ -41,6 +41,42 @@ export interface BedrockChatModelFields extends BaseChatModelParams { streaming?: boolean; } +/** + * Rewrites Bedrock HTTP error messages into actionable text when possible. + * Falls back to the original "Amazon Bedrock ... failed with status N: body" form. + * + * The detection uses two stable substrings from the AWS ValidationException body + * ("on-demand throughput" and "inference profile") rather than the apostrophe-bearing + * phrase "isn't supported", because AWS has been observed serving both straight (') + * and curly (’) apostrophe variants. False positives are harmless: the rewritten + * message still names the bare model ID from the original body. + */ +function rewriteBedrockErrorMessage(status: number, body: string, streaming = false): string { + const prefix = streaming + ? "Amazon Bedrock streaming request failed with status" + : "Amazon Bedrock request failed with status"; + + if ( + status === 400 && + body.includes("on-demand throughput") && + body.includes("inference profile") + ) { + const modelIdMatch = body.match(/model ID ([^\s]+) with/); + const bareId = modelIdMatch?.[1] ?? ""; + // Provider segment of the model ID (e.g. "anthropic" from "anthropic.claude-..."). + // Falls back to a generic placeholder when the ID isn't in . form. + const providerSegment = bareId.includes(".") ? bareId.split(".")[0] : ""; + return ( + `This Bedrock model requires a cross-region inference profile ID, not a bare regional model ID. ` + + `Update the model name in Settings → Models to use one of the prefixed forms: ` + + `global.${providerSegment}. (recommended), us.${providerSegment}., eu.${providerSegment}., or apac.${providerSegment}.. ` + + `The current value "${bareId}" is not accepted by AWS on-demand throughput.` + ); + } + + return `${prefix} ${status}: ${body}`; +} + /** * Lightweight ChatModel integration for Amazon Bedrock using a simple API key header. * This implementation issues JSON requests against the public Bedrock runtime endpoint. @@ -141,10 +177,13 @@ export class BedrockChatModel extends BaseChatModel return tools.map((tool) => { let inputSchema: Record = { type: "object", properties: {} }; if (tool.schema) { - // Use LangChain's schema conversion utilities - inputSchema = ( - isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema - ) as Record; + // Use LangChain's schema conversion utilities. The result is cast via `unknown` + // because `toJsonSchema` returns a `JsonSchema7Type` union without an index + // signature, which TypeScript cannot directly assign to `Record`. + const converted: unknown = isInteropZodSchema(tool.schema) + ? toJsonSchema(tool.schema) + : tool.schema; + inputSchema = converted as Record; } return { name: tool.name, @@ -198,7 +237,7 @@ export class BedrockChatModel extends BaseChatModel if (!response.ok) { const errorText = await response.text(); - throw new Error(`Amazon Bedrock request failed with status ${response.status}: ${errorText}`); + throw new Error(rewriteBedrockErrorMessage(response.status, errorText)); } const data = (await response.json()) as Record; @@ -280,9 +319,7 @@ export class BedrockChatModel extends BaseChatModel if (!response.ok) { const errorText = await response.text(); - throw new Error( - `Amazon Bedrock streaming request failed with status ${response.status}: ${errorText}` - ); + throw new Error(rewriteBedrockErrorMessage(response.status, errorText, true)); } if (!response.body) {