diff --git a/packages/ai/STATUS.md b/packages/ai/STATUS.md index 0e25ca2e3fb9..bb94db657bf2 100644 --- a/packages/ai/STATUS.md +++ b/packages/ai/STATUS.md @@ -60,7 +60,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently | `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. | | `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | | `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | -| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. | +| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. | ## Highest-Risk Gaps @@ -71,30 +71,31 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle. -9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection. +9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults. ## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| API slice | Package-like entrypoint | Purpose | -| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- | -| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | -| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | -| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | -| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | -| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | -| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | -| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | -| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | -| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | -| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. | -| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | -| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | -| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | -| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | -| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | +| API slice | Package-like entrypoint | Purpose | +| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- | +| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | +| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | +| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | +| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | +| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. | +| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | +| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. | +| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. | +| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices @@ -103,6 +104,5 @@ These are implementation/API slices, not separate npm packages. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. 4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints. 5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models. -6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. -7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. -8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages. +6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. +7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages. diff --git a/packages/ai/src/protocols/utils/bedrock-auth.ts b/packages/ai/src/protocols/utils/bedrock-auth.ts index 37cc451256d3..c322e4b9eea4 100644 --- a/packages/ai/src/protocols/utils/bedrock-auth.ts +++ b/packages/ai/src/protocols/utils/bedrock-auth.ts @@ -22,6 +22,8 @@ const signRequest = (input: { readonly body: string readonly headers: Headers.Headers readonly credentials: Credentials + readonly service: string + readonly name: string }) => Effect.tryPromise({ try: async () => { @@ -34,23 +36,26 @@ const signRequest = (input: { accessKeyId: input.credentials.accessKeyId, secretAccessKey: input.credentials.secretAccessKey, sessionToken: input.credentials.sessionToken, - service: "bedrock", + service: input.service, }).sign() return Object.fromEntries(signed.headers.entries()) }, catch: (error) => ProviderShared.invalidRequest( - `Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, + `${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, ), }) /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */ -export const sigV4 = (credentials: Credentials | undefined) => +export const sigV4 = ( + credentials: Credentials | undefined, + options: { readonly service?: string; readonly name?: string } = {}, +) => Auth.custom((input: AuthInput) => { return Effect.gen(function* () { if (!credentials) { return yield* ProviderShared.invalidRequest( - "Bedrock Converse requires either route bearer auth or AWS credentials configured on the route", + `${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`, ) } const headersForSigning = Headers.set(input.headers, "content-type", "application/json") @@ -59,6 +64,8 @@ export const sigV4 = (credentials: Credentials | undefined) => body: input.body, headers: headersForSigning, credentials, + service: options.service ?? "bedrock", + name: options.name ?? "Bedrock Converse", }) return Headers.setAll(headersForSigning, signed) }) diff --git a/packages/ai/src/providers/amazon-bedrock-mantle.ts b/packages/ai/src/providers/amazon-bedrock-mantle.ts new file mode 100644 index 000000000000..4e9e88eecacf --- /dev/null +++ b/packages/ai/src/providers/amazon-bedrock-mantle.ts @@ -0,0 +1,107 @@ +import { Auth } from "../route/auth" +import type { Route as RouteDef, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" +import { OpenAIChat } from "../protocols/openai-chat" +import { OpenAIResponses } from "../protocols/openai-responses" +import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth" +import { ProviderID, type ModelID } from "../schema" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("amazon-bedrock") + +export type Config = RouteDefaultsInput & { + readonly apiKey?: string + readonly baseURL?: string + readonly credentials?: Credentials + readonly region?: string + readonly providerOptions?: OpenAIProviderOptionsInput +} + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: Credentials + readonly region?: string + readonly providerOptions?: OpenAIProviderOptionsInput +} + +const responsesRoute = OpenAIResponses.route.with({ + id: "bedrock-mantle-responses", + provider: id, +}) + +const chatRoute = OpenAIChat.route.with({ + id: "bedrock-mantle-chat", + provider: id, +}) + +export const routes = [responsesRoute, chatRoute] + +const configuredRoute = (route: RouteDef, input: Config) => { + const region = input.region ?? input.credentials?.region ?? "us-east-1" + const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region } + return route.with({ + endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` }, + auth: + input.apiKey === undefined + ? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" }) + : Auth.bearer(input.apiKey), + }) +} + +const defaults = (input: Config) => { + const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input + return rest +} + +export const configure = (input: Config = {}) => { + const configuredResponsesRoute = configuredRoute(responsesRoute, input) + const configuredChatRoute = configuredRoute(chatRoute, input) + const modelDefaults = defaults(input) + const responses = (modelID: string | ModelID) => + configuredResponsesRoute + .with(withOpenAIOptions(modelID, modelDefaults)) + .model({ id: modelID }) + const chat = (modelID: string | ModelID) => + configuredChatRoute + .with(withOpenAIOptions(modelID, modelDefaults)) + .model({ id: modelID }) + + return { + id, + model: chat, + chat, + responses, + configure, + } +} + +export const provider = configure() + +const config = (settings: Settings): Config => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey") + return { + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + region: settings.region, + } +} + +export const chatModel: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => configure(config(settings)).chat(modelID) +export const responsesModel: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => configure(config(settings)).responses(modelID) +export const model = chatModel diff --git a/packages/ai/src/providers/amazon-bedrock/mantle.ts b/packages/ai/src/providers/amazon-bedrock/mantle.ts new file mode 100644 index 000000000000..5aecf50d1387 --- /dev/null +++ b/packages/ai/src/providers/amazon-bedrock/mantle.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../amazon-bedrock-mantle" +export type { Settings } from "../amazon-bedrock-mantle" diff --git a/packages/ai/src/providers/amazon-bedrock/mantle/chat.ts b/packages/ai/src/providers/amazon-bedrock/mantle/chat.ts new file mode 100644 index 000000000000..4eaddf792c7f --- /dev/null +++ b/packages/ai/src/providers/amazon-bedrock/mantle/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../../amazon-bedrock-mantle" +export type { Settings } from "../../amazon-bedrock-mantle" diff --git a/packages/ai/src/providers/amazon-bedrock/mantle/responses.ts b/packages/ai/src/providers/amazon-bedrock/mantle/responses.ts new file mode 100644 index 000000000000..4f1a4f0c42ac --- /dev/null +++ b/packages/ai/src/providers/amazon-bedrock/mantle/responses.ts @@ -0,0 +1,2 @@ +export { responsesModel as model } from "../../amazon-bedrock-mantle" +export type { Settings } from "../../amazon-bedrock-mantle" diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 8b794f9a3026..752e7e854bf3 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -1,6 +1,7 @@ export * as Anthropic from "./anthropic" export * as AnthropicCompatible from "./anthropic-compatible" export * as AmazonBedrock from "./amazon-bedrock" +export * as AmazonBedrockMantle from "./amazon-bedrock-mantle" export * as Azure from "./azure" export * as Cloudflare from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" diff --git a/packages/ai/test/fixtures/recordings/bedrock-mantle/streams-text.json b/packages/ai/test/fixtures/recordings/bedrock-mantle/streams-text.json new file mode 100644 index 000000000000..e2931d1ed3ec --- /dev/null +++ b/packages/ai/test/fixtures/recordings/bedrock-mantle/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "model": "openai.gpt-oss-120b", + "tags": ["prefix:bedrock-mantle", "provider:amazon-bedrock", "protocol:openai-responses"], + "name": "bedrock-mantle/streams-text", + "recordedAt": "2026-08-02T02:58:39.323Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-mantle.us-east-1.api.aws/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai.gpt-oss-120b\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply with exactly: hello\"}]}],\"store\":false,\"max_output_tokens\":256,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"response\":{\"background\":false,\"created_at\":1785639518.0,\"error\":null,\"id\":\"resp_kroelnmiwoulqwvuxagsqrjzynlzafmp45tkd6vbefwjprjaeqjq\",\"incomplete_details\":null,\"instructions\":null,\"metadata\":null,\"model\":\"openai.gpt-oss-120b\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"service_tier\":\"auto\",\"status\":\"in_progress\",\"temperature\":1.0,\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\"},\"sequence_number\":0,\"type\":\"response.created\"}\n\ndata: {\"response\":{\"background\":false,\"created_at\":1785639518.0,\"error\":null,\"id\":\"resp_kroelnmiwoulqwvuxagsqrjzynlzafmp45tkd6vbefwjprjaeqjq\",\"incomplete_details\":null,\"instructions\":null,\"metadata\":null,\"model\":\"openai.gpt-oss-120b\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"service_tier\":\"auto\",\"status\":\"in_progress\",\"temperature\":1.0,\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\"},\"sequence_number\":1,\"type\":\"response.in_progress\"}\n\ndata: {\"item\":{\"id\":\"msg_bb2e38e30251d49f\",\"status\":\"in_progress\",\"summary\":[],\"type\":\"reasoning\"},\"output_index\":0,\"sequence_number\":2,\"type\":\"response.output_item.added\"}\n\ndata: {\"content_index\":0,\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"part\":{\"text\":\"\",\"type\":\"reasoning_text\"},\"sequence_number\":3,\"type\":\"response.reasoning_part.added\"}\n\ndata: {\"content_index\":0,\"delta\":\"The user says: \\\"Reply with exactly: hello\\\". So we must output exactly \\\"hello\\\".\",\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"sequence_number\":4,\"type\":\"response.reasoning_text.delta\"}\n\ndata: {\"content_index\":0,\"delta\":\" No extra spaces, no punctuation, no formatting. Just the word hello.\",\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"sequence_number\":5,\"type\":\"response.reasoning_text.delta\"}\n\ndata: {\"content_index\":0,\"delta\":\" Ensure no extra newline? Usually a newline is okay but the\",\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"sequence_number\":6,\"type\":\"response.reasoning_text.delta\"}\n\ndata: {\"content_index\":0,\"delta\":\" content should be exactly \\\"hello\\\". We'll output just hello.\",\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"sequence_number\":7,\"type\":\"response.reasoning_text.delta\"}\n\ndata: {\"content_index\":0,\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"sequence_number\":8,\"text\":\"The user says: \\\"Reply with exactly: hello\\\". So we must output exactly \\\"hello\\\". No extra spaces, no punctuation, no formatting. Just the word hello. Ensure no extra newline? Usually a newline is okay but the content should be exactly \\\"hello\\\". We'll output just hello.\",\"type\":\"response.reasoning_text.done\"}\n\ndata: {\"content_index\":0,\"item_id\":\"msg_bb2e38e30251d49f\",\"output_index\":0,\"part\":{\"text\":\"The user says: \\\"Reply with exactly: hello\\\". So we must output exactly \\\"hello\\\". No extra spaces, no punctuation, no formatting. Just the word hello. Ensure no extra newline? Usually a newline is okay but the content should be exactly \\\"hello\\\". We'll output just hello.\",\"type\":\"reasoning_text\"},\"sequence_number\":9,\"type\":\"response.reasoning_part.done\"}\n\ndata: {\"item\":{\"content\":[{\"text\":\"The user says: \\\"Reply with exactly: hello\\\". So we must output exactly \\\"hello\\\". No extra spaces, no punctuation, no formatting. Just the word hello. Ensure no extra newline? Usually a newline is okay but the content should be exactly \\\"hello\\\". We'll output just hello.\",\"type\":\"reasoning_text\"}],\"id\":\"msg_bb2e38e30251d49f\",\"status\":\"completed\",\"summary\":[],\"type\":\"reasoning\"},\"output_index\":0,\"sequence_number\":10,\"type\":\"response.output_item.done\"}\n\ndata: {\"item\":{\"content\":[],\"id\":\"msg_a991ad269bc5f319\",\"role\":\"assistant\",\"status\":\"in_progress\",\"type\":\"message\"},\"output_index\":1,\"sequence_number\":11,\"type\":\"response.output_item.added\"}\n\ndata: {\"content_index\":1,\"item_id\":\"msg_a991ad269bc5f319\",\"output_index\":1,\"part\":{\"annotations\":[],\"logprobs\":[],\"text\":\"\",\"type\":\"output_text\"},\"sequence_number\":12,\"type\":\"response.content_part.added\"}\n\ndata: {\"content_index\":1,\"delta\":\"hello\",\"item_id\":\"msg_a991ad269bc5f319\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":13,\"type\":\"response.output_text.delta\"}\n\ndata: {\"content_index\":1,\"item_id\":\"msg_a991ad269bc5f319\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":14,\"text\":\"hello\",\"type\":\"response.output_text.done\"}\n\ndata: {\"content_index\":1,\"item_id\":\"msg_a991ad269bc5f319\",\"output_index\":1,\"part\":{\"annotations\":[],\"text\":\"hello\",\"type\":\"output_text\"},\"sequence_number\":15,\"type\":\"response.content_part.done\"}\n\ndata: {\"item\":{\"content\":[{\"annotations\":[],\"text\":\"hello\",\"type\":\"output_text\"}],\"id\":\"msg_a991ad269bc5f319\",\"role\":\"assistant\",\"status\":\"completed\",\"type\":\"message\"},\"output_index\":1,\"sequence_number\":16,\"type\":\"response.output_item.done\"}\n\ndata: {\"response\":{\"background\":false,\"created_at\":1785639518.0,\"error\":null,\"id\":\"resp_kroelnmiwoulqwvuxagsqrjzynlzafmp45tkd6vbefwjprjaeqjq\",\"incomplete_details\":null,\"instructions\":null,\"metadata\":null,\"model\":\"openai.gpt-oss-120b\",\"object\":\"response\",\"output\":[{\"content\":[{\"text\":\"The user says: \\\"Reply with exactly: hello\\\". So we must output exactly \\\"hello\\\". No extra spaces, no punctuation, no formatting. Just the word hello. Ensure no extra newline? Usually a newline is okay but the content should be exactly \\\"hello\\\". We'll output just hello.\",\"type\":\"reasoning_text\"}],\"id\":\"msg_bb2e38e30251d49f\",\"status\":\"completed\",\"summary\":[],\"type\":\"reasoning\"},{\"content\":[{\"annotations\":[],\"text\":\"hello\",\"type\":\"output_text\"}],\"id\":\"msg_a991ad269bc5f319\",\"role\":\"assistant\",\"status\":\"completed\",\"type\":\"message\"}],\"parallel_tool_calls\":true,\"service_tier\":\"auto\",\"status\":\"completed\",\"temperature\":1.0,\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":72,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":69,\"output_tokens_details\":{\"reasoning_tokens\":59},\"total_tokens\":141}},\"sequence_number\":17,\"type\":\"response.completed\"}\n\n" + } + } + ] +} diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 9748289b80be..1e5caf308cd7 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -23,12 +23,16 @@ describe("provider package entrypoints", () => { import("@opencode-ai/ai/providers/google-vertex/messages"), import("@opencode-ai/ai/providers/openrouter"), import("@opencode-ai/ai/providers/xai"), + import("@opencode-ai/ai/providers/amazon-bedrock/mantle"), + import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"), + import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) expect(modules[8].model).toBe(modules[9].model) expect(modules[12].model).toBe(modules[13].model) + expect(modules[19].model).toBe(modules[20].model) }) test("maps OpenRouter and xAI package settings onto executable models", async () => { diff --git a/packages/ai/test/provider/bedrock-mantle.test.ts b/packages/ai/test/provider/bedrock-mantle.test.ts new file mode 100644 index 000000000000..3345071ecc71 --- /dev/null +++ b/packages/ai/test/provider/bedrock-mantle.test.ts @@ -0,0 +1,111 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import { AmazonBedrockMantle } from "../../src/providers" +import { compileRequest, LLMClient } from "../../src/route/client" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { recordedTests } from "../recorded-test" + +const credentials = { + region: "us-east-2", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", +} + +describe("Amazon Bedrock Mantle provider", () => { + it.effect("uses Chat by default and exposes Responses", () => + Effect.gen(function* () { + const provider = AmazonBedrockMantle.configure({ credentials }) + const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" })) + const responses = yield* compileRequest( + LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }), + ) + + expect(chat).toMatchObject({ + route: "bedrock-mantle-chat", + protocol: "openai-chat", + body: { model: "openai.gpt-oss-120b" }, + }) + expect(responses).toMatchObject({ + route: "bedrock-mantle-responses", + protocol: "openai-responses", + body: { model: "openai.gpt-oss-120b", store: false }, + }) + }), + ) + + it.effect("uses the Mantle endpoint and signing service", () => + Effect.gen(function* () { + const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] + const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b") + yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request) + seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined }) + return input.respond("", { headers: { "content-type": "text/event-stream" } }) + }), + ), + ), + Effect.flip, + ) + + expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses") + expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request") + }), + ) + + it.effect("supports bearer authentication and custom base URLs", () => + Effect.gen(function* () { + const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] + const model = AmazonBedrockMantle.configure({ + apiKey: "test-key", + baseURL: "https://mantle.test/v1", + }).chat("openai.gpt-oss-safeguard-20b") + yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request) + seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined }) + return input.respond("", { headers: { "content-type": "text/event-stream" } }) + }), + ), + ), + Effect.flip, + ) + + expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }]) + }), + ) +}) + +const recorded = recordedTests({ + prefix: "bedrock-mantle", + provider: "amazon-bedrock", + protocol: "openai-responses", + requires: ["AWS_BEARER_TOKEN_BEDROCK"], + metadata: { model: "openai.gpt-oss-120b" }, +}) + +describe("Amazon Bedrock Mantle recorded", () => { + recorded.effect("streams text", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: AmazonBedrockMantle.configure({ + apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture", + region: "us-east-1", + }).responses("openai.gpt-oss-120b"), + prompt: "Reply with exactly: hello", + generation: { maxTokens: 256, temperature: 0 }, + }), + ) + + expect(response.text.trim().toLowerCase()).toBe("hello") + }), + ) +}) diff --git a/packages/core/src/aisdk-native.ts b/packages/core/src/aisdk-native.ts index 0d888bfc1b8d..9e8a587591c1 100644 --- a/packages/core/src/aisdk-native.ts +++ b/packages/core/src/aisdk-native.ts @@ -10,32 +10,103 @@ export interface Mapping { readonly body?: Readonly> } -export function map(packageName: string | undefined, settings: Readonly>): Mapping | undefined { - const baseSettings = mapBaseSettings(settings) - switch (packageName) { +export interface MapInput { + readonly packageName: string | undefined + readonly settings: Readonly> + readonly modelID: string + readonly hasCredential?: boolean +} + +export function map(input: MapInput): Mapping | undefined { + const baseSettings = mapBaseSettings(input.settings) + switch (input.packageName) { + case "@ai-sdk/amazon-bedrock/mantle": + return mapBedrockMantle(input, baseSettings) case "@ai-sdk/google": return { package: "@opencode-ai/ai/providers/google", settings: { ...baseSettings, - ...mapAPIKey(settings), - ...mapGoogleOptions(settings), + ...mapAPIKey(input.settings), + ...mapGoogleOptions(input.settings), }, } case "@openrouter/ai-sdk-provider": - return mapOpenRouter(settings, baseSettings) + return mapOpenRouter(input.settings, baseSettings) case "@ai-sdk/xai": return { package: "@opencode-ai/ai/providers/xai", settings: { ...baseSettings, - ...mapAPIKey(settings), - ...mapXAIOptions(settings), + ...mapAPIKey(input.settings), + ...mapXAIOptions(input.settings), }, } } } +function mapBedrockMantle(input: MapInput, baseSettings: Readonly>): Mapping | undefined { + const settings = input.settings + const apiKey = + typeof settings.apiKey === "string" + ? settings.apiKey + : typeof settings.bearerToken === "string" + ? settings.bearerToken + : undefined + const credentials = mapBedrockCredentials(settings) + if (!input.hasCredential && apiKey === undefined && credentials === undefined) return undefined + const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b" + return { + package: `@opencode-ai/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`, + settings: { + ...baseSettings, + ...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string" + ? { baseURL: settings.endpoint } + : {}), + ...(apiKey === undefined ? {} : { apiKey }), + ...(credentials === undefined ? {} : { credentials }), + ...(typeof settings.region === "string" ? { region: settings.region } : {}), + ...mapOpenAIOptions(settings), + }, + } +} + +function mapBedrockCredentials(settings: Readonly>) { + const credentials = isRecord(settings.credentials) ? settings.credentials : settings + const region = + typeof settings.region === "string" + ? settings.region + : typeof credentials.region === "string" + ? credentials.region + : undefined + if ( + region === undefined || + typeof credentials.accessKeyId !== "string" || + typeof credentials.secretAccessKey !== "string" + ) + return undefined + return { + region, + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + ...(typeof credentials.sessionToken === "string" ? { sessionToken: credentials.sessionToken } : {}), + } +} + +function mapOpenAIOptions(settings: Readonly>) { + const options = { + ...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}), + ...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}), + ...(Array.isArray(settings.include) ? { include: settings.include } : {}), + ...(typeof settings.store === "boolean" ? { store: settings.store } : {}), + ...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}), + ...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}), + ...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}), + } + if (Object.keys(options).length === 0) return {} + return { providerOptions: { openai: options } } +} + function mapBaseSettings(settings: Readonly>) { return { ...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}), diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index bcfa5aae5076..277490923491 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -176,14 +176,20 @@ export const fromCatalogModel = ( ) } const configured = { ...resolved.settings, ...credential?.metadata } - const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined + const mapping = Provider.isAISDK(resolved.package) + ? AISDKNative.map({ + packageName, + settings: configured, + modelID: resolved.modelID ?? resolved.id, + hasCredential: key !== undefined, + }) + : undefined const native = mapping?.package ?? resolved.package if (Provider.isAISDK(resolved.package) && !mapping) { if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved)) const runtime = produce(resolved, (draft) => { draft.settings = Provider.mergeOverlay(draft.settings, { - ...(credential?.type === "key" ? { apiKey: credential.key } : {}), - ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), + ...nativeCredentialSettings(resolved.package ?? "", credential), ...credential?.metadata, }) }) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 629fe4cb66e2..869e0c9ee716 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -34,6 +34,15 @@ export type ProviderPackage = ProviderPackageDefinition const packages = new Map>() const builtins = new Map Promise>([ ["@opencode-ai/ai/providers/amazon-bedrock", () => import("@opencode-ai/ai/providers/amazon-bedrock")], + ["@opencode-ai/ai/providers/amazon-bedrock/mantle", () => import("@opencode-ai/ai/providers/amazon-bedrock/mantle")], + [ + "@opencode-ai/ai/providers/amazon-bedrock/mantle/chat", + () => import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"), + ], + [ + "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses", + () => import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"), + ], ["@opencode-ai/ai/providers/anthropic", () => import("@opencode-ai/ai/providers/anthropic")], ["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")], ["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")], diff --git a/packages/core/test/aisdk-native.test.ts b/packages/core/test/aisdk-native.test.ts index 6e243852af90..841bc061d94b 100644 --- a/packages/core/test/aisdk-native.test.ts +++ b/packages/core/test/aisdk-native.test.ts @@ -1,10 +1,92 @@ import { describe, expect, test } from "bun:test" import { AISDKNative } from "@opencode-ai/core/aisdk-native" +const map = (packageName: string, settings: Readonly>, modelID = "test-model") => + AISDKNative.map({ packageName, settings, modelID }) + describe("AISDKNative", () => { + test("maps Bedrock Mantle models to their supported native APIs", () => { + const settings = { + bearerToken: "token", + region: "us-west-2", + baseURL: "https://mantle.test/v1", + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + } + + expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({ + package: "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses", + settings: { + apiKey: "token", + baseURL: "https://mantle.test/v1", + region: "us-west-2", + providerOptions: { + openai: { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + }, + }) + expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe( + "@opencode-ai/ai/providers/amazon-bedrock/mantle/chat", + ) + }) + + test("maps static Bedrock Mantle credentials without leaking connection options", () => { + expect( + map( + "@ai-sdk/amazon-bedrock/mantle", + { + credentials: { + accessKeyId: "key", + secretAccessKey: "secret", + sessionToken: "session", + }, + region: "eu-west-1", + profile: "ignored", + credentialProvider: "ignored", + fetch: "ignored", + store: false, + }, + "openai.gpt-oss-120b", + ), + ).toEqual({ + package: "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses", + settings: { + credentials: { + accessKeyId: "key", + secretAccessKey: "secret", + sessionToken: "session", + region: "eu-west-1", + }, + region: "eu-west-1", + providerOptions: { openai: { store: false } }, + }, + }) + }) + + test("keeps Bedrock Mantle on the AI SDK when native static auth is unavailable", () => { + expect( + map("@ai-sdk/amazon-bedrock/mantle", { region: "us-east-1", profile: "production" }, "openai.gpt-oss-120b"), + ).toBeUndefined() + }) + + test("maps the legacy Bedrock endpoint override", () => { + expect( + map( + "@ai-sdk/amazon-bedrock/mantle", + { bearerToken: "token", endpoint: "https://mantle.private/v1", region: "us-east-1" }, + "openai.gpt-oss-120b", + ), + ).toMatchObject({ settings: { baseURL: "https://mantle.private/v1" } }) + }) + test("maps OpenRouter settings to native destinations", () => { expect( - AISDKNative.map("@openrouter/ai-sdk-provider", { + map("@openrouter/ai-sdk-provider", { appName: "OpenCode", appUrl: "https://opencode.ai", headers: { "x-openrouter-title": "Configured", "x-provider-api-keys": "Configured BYOK" }, @@ -40,7 +122,7 @@ describe("AISDKNative", () => { test("maps every Google thinking setting", () => { expect( - AISDKNative.map("@ai-sdk/google", { + map("@ai-sdk/google", { cachedContent: "cachedContents/example", safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }], serviceTier: "flex", @@ -72,7 +154,7 @@ describe("AISDKNative", () => { test("maps Google thinking settings independently", () => { for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) { - expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({ + expect(map("@ai-sdk/google", { thinkingConfig })).toMatchObject({ settings: { providerOptions: { gemini: { thinkingConfig } } }, }) } @@ -80,7 +162,7 @@ describe("AISDKNative", () => { test("maps Google request options without thinking settings", () => { expect( - AISDKNative.map("@ai-sdk/google", { + map("@ai-sdk/google", { cachedContent: "cachedContents/example", safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }], serviceTier: "future-tier", @@ -100,7 +182,7 @@ describe("AISDKNative", () => { test("maps supported xAI settings", () => { expect( - AISDKNative.map("@ai-sdk/xai", { + map("@ai-sdk/xai", { apiKey: "secret", baseURL: "https://xai.example/v1", reasoningEffort: "custom", @@ -125,7 +207,7 @@ describe("AISDKNative", () => { test("omits invalid and unsupported xAI settings", () => { expect( - AISDKNative.map("@ai-sdk/xai", { + map("@ai-sdk/xai", { reasoningEffort: 10, store: "yes", include: ["unknown"], diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 55c641123216..5f552d861cf2 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -42,6 +42,35 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => }) describe("ModelResolver", () => { + it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () => + Effect.gen(function* () { + const credential = Credential.Key.make({ type: "key", key: "secret" }) + const responses = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), { + modelID: "openai.gpt-oss-120b", + settings: { region: "us-east-2" }, + }), + credential, + ) + const chat = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), { + modelID: "openai.gpt-oss-safeguard-20b", + settings: { region: "us-east-2" }, + }), + credential, + ) + + expect(responses.route).toMatchObject({ + id: "bedrock-mantle-responses", + endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" }, + }) + expect(chat.route).toMatchObject({ + id: "bedrock-mantle-chat", + endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" }, + }) + }), + ) + it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () => Effect.gen(function* () { const catalog = model(Provider.aisdk("@ai-sdk/openai"), {