From 3883892814d9d9de088fed54eb9ae410894b300a Mon Sep 17 00:00:00 2001 From: Burak Varli Date: Fri, 24 Jul 2026 18:02:57 +0000 Subject: [PATCH] feat(ai): support Claude Opus 5 on Bedrock --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 13 +++++++++- .../ai/src/api/bedrock-converse-stream.ts | 12 +++++++--- packages/ai/src/utils/error-body.ts | 11 ++++++--- packages/ai/test/bedrock-models.test.ts | 5 ++++ .../ai/test/bedrock-thinking-payload.test.ts | 20 ++++++++++++++++ packages/ai/test/error-body.test.ts | 21 ++++++++++++++++ .../provider-error-body-regression.test.ts | 24 +++++++++++++++++++ packages/ai/test/supports-xhigh.test.ts | 14 +++++++++++ 9 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9e1b31c6fe2..3c360929511 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways ([#5871](https://github.com/earendil-works/pi/issues/5871)) +- Added Claude Opus 5 support for Anthropic and Amazon Bedrock with adaptive thinking, inference profiles, prompt caching, and preserved AWS validation messages ([#7081](https://github.com/earendil-works/pi/pull/7081) by [@unexge](https://github.com/unexge)). ### Changed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 712d8164358..be2e08a76aa 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -292,6 +292,7 @@ const ANT_LING_RING_THINKING_LEVEL_MAP = { xhigh: "xhigh", } as const; +const BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]); const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]); const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ "gpt-5.4", @@ -487,6 +488,7 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { modelId.includes("opus-4-8") || modelId.includes("opus-4.8") || modelId.includes("opus-5") || + modelId.includes("opus.5") || modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4.6") || modelId.includes("sonnet-5") || @@ -497,7 +499,14 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean { const id = modelId.toLowerCase(); - return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8") || id.includes("opus-5"); + return ( + id.includes("opus-4-7") || + id.includes("opus-4.7") || + id.includes("opus-4-8") || + id.includes("opus-4.8") || + id.includes("opus-5") || + id.includes("opus.5") + ); } const OPENAI_COMPLETIONS_DEFAULT_COMPAT = { @@ -764,6 +773,7 @@ function applyThinkingLevelMetadata(model: Model): void { model.id.includes("opus-4-8") || model.id.includes("opus-4.8") || model.id.includes("opus-5") || + model.id.includes("opus.5") || model.id.includes("sonnet-5") || model.id.includes("sonnet.5") ) { @@ -1054,6 +1064,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + if (BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(modelId)) continue; let id = modelId; diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 4c10bc923af..4c8a42051dd 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -582,6 +582,7 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("opus-4-8") || + s.includes("opus-5") || s.includes("sonnet-4-6") || s.includes("sonnet-5") || s.includes("fable-5"), @@ -591,7 +592,12 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { const candidates = getModelMatchCandidates(model.id, model.name); return candidates.some( - (s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-5") || s.includes("fable-5"), + (s) => + s.includes("opus-4-7") || + s.includes("opus-4-8") || + s.includes("opus-5") || + s.includes("sonnet-5") || + s.includes("fable-5"), ); } @@ -670,8 +676,8 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: Pr if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true; return false; } - // Claude 5 models (fable-5, sonnet-5) - if (candidates.some((s) => s.includes("fable-5") || s.includes("sonnet-5"))) return true; + // Claude 5 models (fable-5, opus-5, sonnet-5) + if (candidates.some((s) => s.includes("fable-5") || s.includes("opus-5") || s.includes("sonnet-5"))) return true; // Claude 4.x models (opus-4, sonnet-4, haiku-4) if (candidates.some((s) => s.includes("-4-"))) return true; // Claude 3.7 Sonnet diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts index c3215cdf023..d49e09064fe 100644 --- a/packages/ai/src/utils/error-body.ts +++ b/packages/ai/src/utils/error-body.ts @@ -69,9 +69,9 @@ function extractStatus(error: SdkErrorShape): number | undefined { /** * Probe the raw body reason, first usable hit wins, in SDK-field order: * `body` string (Mistral) → `error` parsed JSON body object (`openai` SDK's - * `this.error`) → `$response.body` (Bedrock). Empty objects are treated as no - * body so an empty parsed body does not surface as `"{}"`. The chosen body is - * truncated to the cap. + * `this.error`) → `$response.body` (Bedrock). Empty objects and unread response + * streams are treated as no body so they do not surface as `"{}"` or serialized + * stream internals. The chosen body is truncated to the cap. */ function extractBody(error: SdkErrorShape): string | undefined { const bodyText = pickBodyText(error); @@ -86,10 +86,15 @@ function pickBodyText(error: SdkErrorShape): string | undefined { if (isNonEmptyObject(error.error)) return safeJsonStringify(error.error); const responseBody = error.$response?.body; if (typeof responseBody === "string") return responseBody; + if (isReadableStreamLike(responseBody)) return undefined; if (isNonEmptyObject(responseBody)) return safeJsonStringify(responseBody); return undefined; } +function isReadableStreamLike(value: unknown): boolean { + return typeof value === "object" && value !== null && "pipe" in value && typeof value.pipe === "function"; +} + function isNonEmptyObject(value: unknown): boolean { return typeof value === "object" && value !== null && Object.keys(value).length > 0; } diff --git a/packages/ai/test/bedrock-models.test.ts b/packages/ai/test/bedrock-models.test.ts index 08f95e7aba0..02e8b8f16b4 100644 --- a/packages/ai/test/bedrock-models.test.ts +++ b/packages/ai/test/bedrock-models.test.ts @@ -29,6 +29,11 @@ describe("Amazon Bedrock Models", () => { console.log(`Found ${models.length} Bedrock models`); }); + it("exposes Claude Opus 5 through an inference profile only", () => { + expect(models.some((model) => model.id === "global.anthropic.claude-opus-5")).toBe(true); + expect(models.some((model) => model.id === "anthropic.claude-opus-5")).toBe(false); + }); + if (hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST) { for (const model of models) { it(`should make a simple request with ${model.id}`, { timeout: 10_000 }, async () => { diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index ad58a4d9efa..acc37057429 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -103,6 +103,26 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); + it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5"); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + + it("maps xhigh reasoning to effort=xhigh for Claude Opus 5", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5"); + + const payload = await capturePayload(model, { reasoning: "xhigh" }); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts index 62152f5e039..1ba80623f36 100644 --- a/packages/ai/test/error-body.test.ts +++ b/packages/ai/test/error-body.test.ts @@ -64,6 +64,27 @@ describe("normalizeProviderError", () => { expect(norm.messageCarriesBody).toBe(false); }); + it("ignores a Bedrock response stream instead of serializing its internals", () => { + const error = Object.assign( + new Error("Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported."), + { + name: "ValidationException", + $metadata: { httpStatusCode: 400 }, + $response: { + statusCode: 400, + body: { pipe: () => undefined, _events: { close: [null, null] } }, + }, + }, + ); + + const norm = normalizeProviderError(error); + + expect(norm.status).toBe(400); + expect(norm.body).toBeUndefined(); + expect(norm.message).toContain("on-demand throughput isn't supported"); + expect(norm.messageCarriesBody).toBe(true); + }); + it("JSON-stringifies a non-Error thrown value", () => { const norm = normalizeProviderError({ reason: "boom" }); diff --git a/packages/ai/test/provider-error-body-regression.test.ts b/packages/ai/test/provider-error-body-regression.test.ts index 5649bba6cd5..d894c3db004 100644 --- a/packages/ai/test/provider-error-body-regression.test.ts +++ b/packages/ai/test/provider-error-body-regression.test.ts @@ -186,4 +186,28 @@ describe("provider error body passthrough (per-tier regression)", () => { expect(output.errorMessage).toContain("blocked by gateway WAF"); expect(output.errorMessage).not.toContain("Unknown: UnknownError"); }); + + it("bedrock preserves the SDK validation message when the response body is a stream", async () => { + bedrockMock.sendError = Object.assign( + new Error( + "Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported. Retry with an inference profile.", + ), + { + name: "ValidationException", + $metadata: { httpStatusCode: 400 }, + $response: { + statusCode: 400, + body: { pipe: () => undefined, _readableState: { buffer: [], length: 0 } }, + }, + }, + ); + + const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5"); + const output = await drainResult(streamSimpleBedrock(model, { messages: context.messages }, {})); + + expect(output.stopReason).toBe("error"); + expect(output.errorMessage).toContain("on-demand throughput isn't supported"); + expect(output.errorMessage).toContain("inference profile"); + expect(output.errorMessage).not.toContain("_readableState"); + }); }); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 6727903217b..29e16fa8280 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -16,6 +16,13 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("max"); }); + it("includes xhigh and max for Anthropic Opus 5 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-opus-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).toContain("max"); + }); + it("includes max but not xhigh for Anthropic Sonnet 4.6 on anthropic-messages API", () => { const model = getModel("anthropic", "claude-sonnet-4-6"); expect(model).toBeDefined(); @@ -133,6 +140,13 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh"); }); + it("includes xhigh and max for Bedrock Claude Opus 5", () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).toContain("max"); + }); + it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); expect(model).toBeDefined();