From bf7f0cb521c522d0067173127e164eaab1062dff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:16:22 -0500 Subject: [PATCH] feat(ai): add native Bedrock Mantle support (#40119) Co-authored-by: Aiden Cline --- packages/ai/STATUS.md | 46 ++++---- .../ai/src/protocols/utils/bedrock-auth.ts | 15 ++- .../ai/src/providers/amazon-bedrock-mantle.ts | 107 +++++++++++++++++ .../ai/src/providers/amazon-bedrock/mantle.ts | 2 + .../providers/amazon-bedrock/mantle/chat.ts | 2 + .../amazon-bedrock/mantle/responses.ts | 2 + packages/ai/src/providers/index.ts | 1 + .../bedrock-mantle/streams-text.json | 29 +++++ packages/ai/test/provider-package.test.ts | 4 + .../ai/test/provider/bedrock-mantle.test.ts | 111 ++++++++++++++++++ packages/core/src/aisdk-native.ts | 87 ++++++++++++-- packages/core/src/model-resolver.ts | 12 +- packages/core/src/provider.ts | 9 ++ packages/core/test/aisdk-native.test.ts | 94 ++++++++++++++- packages/core/test/model-resolver.test.ts | 29 +++++ 15 files changed, 506 insertions(+), 44 deletions(-) create mode 100644 packages/ai/src/providers/amazon-bedrock-mantle.ts create mode 100644 packages/ai/src/providers/amazon-bedrock/mantle.ts create mode 100644 packages/ai/src/providers/amazon-bedrock/mantle/chat.ts create mode 100644 packages/ai/src/providers/amazon-bedrock/mantle/responses.ts create mode 100644 packages/ai/test/fixtures/recordings/bedrock-mantle/streams-text.json create mode 100644 packages/ai/test/provider/bedrock-mantle.test.ts diff --git a/packages/ai/STATUS.md b/packages/ai/STATUS.md index 0e25ca2e3f..bb94db657b 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 37cc451256..c322e4b9ee 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 0000000000..4e9e88eeca --- /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 0000000000..5aecf50d13 --- /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 0000000000..4eaddf792c --- /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 0000000000..4f1a4f0c42 --- /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 8b794f9a30..752e7e854b 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 0000000000..e2931d1ed3 --- /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 9748289b80..1e5caf308c 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 0000000000..3345071ecc --- /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 0d888bfc1b..9e8a587591 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 b334d5cd07..a9a5b6ef6c 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 629fe4cb66..869e0c9ee7 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 6e243852af..841bc061d9 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 9a1956d6fb..98a5b64679 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"), {