Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion packages/ai/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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") ||
Expand All @@ -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 = {
Expand Down Expand Up @@ -764,6 +773,7 @@ function applyThinkingLevelMetadata(model: Model<any>): 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")
) {
Expand Down Expand Up @@ -1054,6 +1064,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
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;

Expand Down
12 changes: 9 additions & 3 deletions packages/ai/src/api/bedrock-converse-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
);
}

Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions packages/ai/src/utils/error-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/test/bedrock-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/ai/test/bedrock-thinking-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
21 changes: 21 additions & 0 deletions packages/ai/test/error-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
24 changes: 24 additions & 0 deletions packages/ai/test/provider-error-body-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
14 changes: 14 additions & 0 deletions packages/ai/test/supports-xhigh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading