Files
oh-my-pi/packages/ai/test/bedrock-inference-profile.test.ts
T
can1357 e06ccbd907 Merge PR #7080: fix(ai): add authenticated Bedrock Mantle routing (@anatoli-tsinovoy)
# Conflicts:
#	packages/ai/src/registry/registry.ts
#	packages/catalog/scripts/generated-policies.ts
#	packages/catalog/src/models.json
2026-08-03 14:36:52 +02:00

274 lines
8.6 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock";
import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Effort } from "@oh-my-pi/pi-catalog/effort";
import { removeWithRetries } from "../../utils/src/temp";
import { withEnv } from "./helpers";
const profileArn = "arn:aws:bedrock:us-east-2:1234567890:application-inference-profile/company-opus-48";
const profileModel: Model<"bedrock-converse-stream"> = buildModel({
id: profileArn,
name: "Bedrock inference profile",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1000000,
maxTokens: 128000,
thinking: {
mode: "anthropic-adaptive",
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.Max],
supportsDisplay: true,
},
});
function userContext(): Context {
return {
messages: [{ role: "user", content: "Say hello", timestamp: 0 }],
};
}
describe("Bedrock inference profile ARNs", () => {
test("routes requests to the ARN region and preserves the ARN model id", async () => {
const calls: string[] = [];
const customFetch: FetchImpl = Object.assign(
async (input: string | URL | Request, _init?: RequestInit) => {
calls.push(String(input instanceof Request ? input.url : input));
return new Response("nope", { status: 418 });
},
{ preconnect: fetch.preconnect },
);
const result = await streamBedrock(profileModel, userContext(), {
bearerToken: "test-token",
fetch: customFetch,
maxTokens: 16,
}).result();
expect(result.stopReason).toBe("error");
expect(calls).toEqual([
`https://bedrock-runtime.us-east-2.amazonaws.com/model/${encodeURIComponent(profileArn)}/converse-stream`,
]);
});
test("replays captured thinking signatures for ARN profiles", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Plan the change", timestamp: 0 },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Inspect the implementation", thinkingSignature: "signed-reasoning" },
{ type: "text", text: "I found the relevant code." },
],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: profileArn,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
},
{ role: "user", content: "Continue", timestamp: 2 },
],
};
const controller = new AbortController();
controller.abort();
const { promise, resolve } = Promise.withResolvers<unknown>();
void streamBedrock(profileModel, context, {
bearerToken: "test-token",
signal: controller.signal,
reasoning: Effort.High,
maxTokens: 16,
onPayload: payload => {
resolve(payload);
},
});
expect(await promise).toMatchObject({
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
},
messages: [
{ role: "user", content: [{ text: "Plan the change" }] },
{
role: "assistant",
content: [
{
reasoningContent: {
reasoningText: {
text: "Inspect the implementation",
signature: "signed-reasoning",
},
},
},
{ text: "I found the relevant code." },
],
},
{ role: "user", content: [{ text: "Continue" }] },
],
});
});
});
function bedrockModel(id: string): Model<"bedrock-converse-stream"> {
return buildModel({
id,
name: id,
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1000000,
maxTokens: 128000,
});
}
async function capturedRequestHost(
model: Model<"bedrock-converse-stream">,
options: { region?: string; profile?: string } = {},
): Promise<string> {
const calls: string[] = [];
const customFetch: FetchImpl = Object.assign(
async (input: string | URL | Request, _init?: RequestInit) => {
calls.push(String(input instanceof Request ? input.url : input));
return new Response("nope", { status: 418 });
},
{ preconnect: fetch.preconnect },
);
const result = await streamBedrock(model, userContext(), {
bearerToken: "test-token",
fetch: customFetch,
maxTokens: 16,
...options,
}).result();
expect(result.stopReason).toBe("error");
expect(calls).toHaveLength(1);
return new URL(calls[0]).host;
}
describe("Bedrock cross-region inference-profile geo routing", () => {
// A `us-east-1` ambient region exercises the mismatch-correction path: a non-`us`
// geo profile must be rerouted off the ambient `us` region, not pass through it.
const US_AMBIENT = { AWS_REGION: "us-east-1", AWS_DEFAULT_REGION: undefined } as const;
// Repro: an `eu.` profile defaulted to us-east-1 → HTTP 400 "The provided model identifier is invalid."
test("routes an eu. profile to an EU region instead of us-east-1", async () => {
await withEnv(US_AMBIENT, async () => {
expect(await capturedRequestHost(bedrockModel("eu.anthropic.claude-fable-5"))).toBe(
"bedrock-runtime.eu-west-1.amazonaws.com",
);
});
});
test("routes an au. profile to the Australia region", async () => {
await withEnv(US_AMBIENT, async () => {
expect(await capturedRequestHost(bedrockModel("au.anthropic.claude-opus-4-8"))).toBe(
"bedrock-runtime.ap-southeast-2.amazonaws.com",
);
});
});
test("routes a jp. profile to a Japan region", async () => {
await withEnv(US_AMBIENT, async () => {
expect(await capturedRequestHost(bedrockModel("jp.anthropic.claude-opus-4-8"))).toBe(
"bedrock-runtime.ap-northeast-1.amazonaws.com",
);
});
});
test("falls back to us-east-1 for a geo profile when no ambient region is set", async () => {
await withEnv({ AWS_REGION: undefined, AWS_DEFAULT_REGION: undefined }, async () => {
expect(await capturedRequestHost(bedrockModel("us.anthropic.claude-opus-4-8"))).toBe(
"bedrock-runtime.us-east-1.amazonaws.com",
);
});
});
test("leaves region-agnostic global. profiles on the ambient region", async () => {
await withEnv({ AWS_REGION: undefined, AWS_DEFAULT_REGION: undefined }, async () => {
expect(await capturedRequestHost(bedrockModel("global.anthropic.claude-opus-4-8"))).toBe(
"bedrock-runtime.us-east-1.amazonaws.com",
);
});
});
test("honors a same-geo ambient region for a geo-prefixed profile", async () => {
await withEnv({ AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: undefined }, async () => {
expect(await capturedRequestHost(bedrockModel("eu.anthropic.claude-opus-4-8"))).toBe(
"bedrock-runtime.eu-central-1.amazonaws.com",
);
});
});
test("uses the selected profile region when environment regions are absent", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "bedrock-profile-region-"));
try {
const configPath = path.join(tmp, "config");
await Bun.write(configPath, "[profile regional]\nregion = eu-west-2\n");
await withEnv(
{
AWS_REGION: undefined,
AWS_DEFAULT_REGION: undefined,
AWS_PROFILE: "regional",
AWS_CONFIG_FILE: configPath,
},
async () => {
expect(
await capturedRequestHost(bedrockModel("eu.anthropic.claude-opus-4-8"), {
profile: "regional",
}),
).toBe("bedrock-runtime.eu-west-2.amazonaws.com");
},
);
} finally {
await removeWithRetries(tmp);
}
});
test("explicit per-request region wins over the geo prefix and ambient region", async () => {
await withEnv({ AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: undefined }, async () => {
expect(await capturedRequestHost(bedrockModel("eu.anthropic.claude-opus-4-8"), { region: "eu-west-3" })).toBe(
"bedrock-runtime.eu-west-3.amazonaws.com",
);
});
});
});
describe("Bedrock error handling", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
test.each([
["undefined", undefined],
["BigInt", 1n],
["circular object", circular],
])("surfaces a stream error when %s is thrown", async (_name, thrown) => {
const result = await streamBedrock(profileModel, userContext(), {
bearerToken: "test-token",
maxTokens: 16,
onPayload: () => {
throw thrown;
},
}).result();
expect(result.stopReason).toBe("error");
});
});