fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID (#2472)

* fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID

When users configure a bare regional model ID (e.g. anthropic.claude-sonnet-4-6) on
Bedrock, AWS returns a 400 with a cryptic JSON blob. The raw error gave no hint that
the fix is to switch to a cross-region inference profile prefix. This rewrites that
specific 400 to a one-line actionable message pointing users to Settings → Models and
listing the global./us./eu./apac. prefix options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): generalize inference-profile sentinel and provider segment

Addresses two Codex review concerns and a CI build regression on PR #2472:

- Sentinel: match on the apostrophe-free phrases "on-demand throughput" and
  "inference profile" instead of "isn't supported", so we catch AWS error
  bodies that use the curly-apostrophe variant "isn't supported" as well.
- Guidance: derive the prefix-form provider segment (e.g. "anthropic",
  "meta", "amazon") from the bare model ID extracted from the error body,
  so non-Anthropic Bedrock models get the correct global.<provider>.<id>
  guidance instead of being told to switch to global.anthropic.<id>.
- Restore the `as Record<string, unknown>` cast (via `unknown`) on the
  Zod-to-JSON-Schema conversion. The previous reformat dropped the cast
  and broke `tsc -noEmit` with a JsonSchema7Type / Record<string,unknown>
  mismatch, which surfaced as the build (22.x) CI failure on this branch.

New tests:
- "rewrites the error even when AWS uses a curly apostrophe in 'isn't supported'"
- "uses the provider segment from the bare model ID in the prefix guidance"

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-05-15 17:18:00 -07:00 committed by GitHub
parent de443ae5d9
commit 475d5b9c18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 154 additions and 8 deletions

View file

@ -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 'isnt supported'", async () => {
const curlyApostropheBody = JSON.stringify({
message:
"Invocation of model ID anthropic.claude-sonnet-4-5 with on-demand throughput isnt 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\.<id>/);
});
});

View file

@ -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] ?? "<model-id>";
// Provider segment of the model ID (e.g. "anthropic" from "anthropic.claude-...").
// Falls back to a generic placeholder when the ID isn't in <provider>.<model> form.
const providerSegment = bareId.includes(".") ? bareId.split(".")[0] : "<provider>";
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}.<id> (recommended), us.${providerSegment}.<id>, eu.${providerSegment}.<id>, or apac.${providerSegment}.<id>. ` +
`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<BedrockChatModelCallOptions>
return tools.map((tool) => {
let inputSchema: Record<string, unknown> = { type: "object", properties: {} };
if (tool.schema) {
// Use LangChain's schema conversion utilities
inputSchema = (
isInteropZodSchema(tool.schema) ? toJsonSchema(tool.schema) : tool.schema
) as Record<string, unknown>;
// 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<string, unknown>`.
const converted: unknown = isInteropZodSchema(tool.schema)
? toJsonSchema(tool.schema)
: tool.schema;
inputSchema = converted as Record<string, unknown>;
}
return {
name: tool.name,
@ -198,7 +237,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
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<string, unknown>;
@ -280,9 +319,7 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
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) {