mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
de443ae5d9
commit
475d5b9c18
2 changed files with 154 additions and 8 deletions
|
|
@ -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\.<id>/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue