From c5680a206e4ef0211fee17469684ffa3d9c5a0c9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Jul 2026 18:36:35 +0530 Subject: [PATCH] refactor(ai): make OpenAI Responses extend Open Responses (#38681) --- packages/ai/AGENTS.md | 9 +- packages/ai/README.md | 2 +- packages/ai/STATUS.md | 48 +- packages/ai/src/protocols/index.ts | 1 + packages/ai/src/protocols/open-responses.ts | 949 +++++++++++++++++ packages/ai/src/protocols/openai-chat.ts | 13 +- .../protocols/openai-compatible-responses.ts | 17 +- packages/ai/src/protocols/openai-responses.ts | 995 ++---------------- .../protocols/utils/open-responses-options.ts | 65 ++ .../ai/src/protocols/utils/openai-options.ts | 84 +- .../ai/src/protocols/utils/tool-schema.ts | 3 + .../src/providers/google-vertex-responses.ts | 1 + .../src/providers/open-responses-options.ts | 20 + .../providers/openai-compatible-responses.ts | 7 +- packages/ai/src/providers/openai-options.ts | 18 +- packages/ai/src/route/protocol.ts | 3 +- packages/ai/test/exports.test.ts | 10 +- packages/ai/test/provider-package.test.ts | 8 +- .../openai-compatible-responses.test.ts | 77 +- 19 files changed, 1262 insertions(+), 1068 deletions(-) create mode 100644 packages/ai/src/protocols/open-responses.ts create mode 100644 packages/ai/src/protocols/utils/open-responses-options.ts create mode 100644 packages/ai/src/providers/open-responses-options.ts diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 997615ce0df..f02b5616b22 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -54,7 +54,7 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. A route is the registered, runnable composition of four orthogonal pieces: -- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. +- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. - **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`). - **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result. - **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing` value alongside its protocol. @@ -158,13 +158,14 @@ packages/ai/src/ protocols/ shared.ts ProviderShared toolkit used inside protocol impls openai-chat.ts protocol + route (compose OpenAIChat.protocol) - openai-responses.ts + open-responses.ts provider-neutral Responses protocol baseline + openai-responses.ts OpenAI tools/events/transports composed over OpenResponses anthropic-messages.ts gemini.ts bedrock-converse.ts bedrock-event-stream.ts framing for AWS event-stream binary frames openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL - openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL + openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL utils/ per-protocol helpers (auth, cache, media, tool-stream, ...) providers/ openai-compatible.ts generic Chat helper + family model helpers @@ -175,7 +176,7 @@ packages/ai/src/ tool-runtime.ts narrow one-call typed tool dispatcher ``` -The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. +The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension. ### Shared protocol helpers diff --git a/packages/ai/README.md b/packages/ai/README.md index 6e4d87611ca..597ff8eaab5 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -300,7 +300,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints: - `@opencode-ai/ai/providers/google-vertex/responses` - `@opencode-ai/ai/providers/google-vertex/messages` -Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. +Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. diff --git a/packages/ai/STATUS.md b/packages/ai/STATUS.md index 24c98a426ac..0e25ca2e3fb 100644 --- a/packages/ai/STATUS.md +++ b/packages/ai/STATUS.md @@ -1,6 +1,6 @@ # LLM Provider Parity Status -Last reviewed: 2026-07-17 +Last reviewed: 2026-07-24 This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. @@ -13,26 +13,26 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI ## Current Implementation Snapshot -| Native slice | Source | Current state | Main gaps | -| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | -| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | -| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | -| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | -| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. | -| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. | -| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | -| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | -| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. | -| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. | -| Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. | -| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. | -| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | -| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | -| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | -| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | -| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | -| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | +| Native slice | Source | Current state | Main gaps | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | +| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | +| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | +| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. | +| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. | +| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. | +| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | +| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | +| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. | +| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. | +| Vertex Responses | `src/protocols/open-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through Open Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and an explicit `store: false` Vertex default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. | +| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. | +| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | +| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | +| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | +| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | +| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | +| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | ## V2 Runner Status @@ -65,7 +65,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently ## Highest-Risk Gaps 1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. -2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. +2. The Open Responses adapter is available through a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. 3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. 4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing. 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. @@ -83,13 +83,13 @@ These are implementation/API slices, not separate npm packages. | 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`. | -| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. | +| 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 OpenAI-compatible Responses for Grok 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. | diff --git a/packages/ai/src/protocols/index.ts b/packages/ai/src/protocols/index.ts index bd3ee8eeec1..b677e1a5991 100644 --- a/packages/ai/src/protocols/index.ts +++ b/packages/ai/src/protocols/index.ts @@ -6,3 +6,4 @@ export * as OpenAIImages from "./openai-images" export * as OpenAICompatibleChat from "./openai-compatible-chat" export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenAIResponses from "./openai-responses" +export * as OpenResponses from "./open-responses" diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts new file mode 100644 index 00000000000..4cd54573196 --- /dev/null +++ b/packages/ai/src/protocols/open-responses.ts @@ -0,0 +1,949 @@ +import { Effect, Schema } from "effect" +import { HttpTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + LLMError, + LLMEvent, + Usage, + type FinishReason, + type JsonSchema, + type LLMRequest, + type MediaPart, + type ProviderMetadata, + type ReasoningPart, + type TextPart, + type ToolCallPart, + type ToolDefinition, + type ToolContent, + type ToolResultPart, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { classifyProviderFailure } from "../provider-error" +import { OpenResponsesOptions } from "./utils/open-responses-options" +import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "open-responses" +const NAME = "Open Responses" +const MEDIA_MIMES = new Set([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES]) +export const PATH = "/responses" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const OpenResponsesInputText = Schema.Struct({ + type: Schema.tag("input_text"), + text: Schema.String, +}) +const OpenResponsesInputImage = Schema.Struct({ + type: Schema.tag("input_image"), + image_url: Schema.String, +}) +const OpenResponsesInputFile = Schema.Struct({ + type: Schema.tag("input_file"), + filename: Schema.String, + file_data: Schema.String, + mime_type: Schema.optional(Schema.String), +}) +const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile]) +export type MediaInput = Schema.Schema.Type +const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput]) + +const OpenResponsesOutputText = Schema.Struct({ + type: Schema.tag("output_text"), + text: Schema.String, +}) + +const OpenResponsesReasoningSummaryText = Schema.Struct({ + type: Schema.tag("summary_text"), + text: Schema.String, +}) + +const OpenResponsesReasoningItem = Schema.Struct({ + type: Schema.tag("reasoning"), + id: Schema.optionalKey(Schema.String), + summary: Schema.Array(OpenResponsesReasoningSummaryText), + encrypted_content: optionalNull(Schema.String), +}) + +const OpenResponsesItemReference = Schema.Struct({ + type: Schema.tag("item_reference"), + id: Schema.String, +}) + +// `function_call_output.output` accepts either a plain string or an ordered +// array of content items so tools can return images and files in addition to text. +// https://www.openresponses.org/reference +const OpenResponsesFunctionCallOutputContent = Schema.Union([ + OpenResponsesInputText, + OpenResponsesInputImage, + OpenResponsesInputFile, +]) + +const OpenResponsesFunctionCallOutput = Schema.Union([ + Schema.String, + Schema.Array(OpenResponsesFunctionCallOutputContent), +]) + +const OpenResponsesInputItem = Schema.Union([ + Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), + Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), + Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }), + OpenResponsesReasoningItem, + OpenResponsesItemReference, + Schema.Struct({ + type: Schema.tag("function_call"), + call_id: Schema.String, + name: Schema.String, + arguments: Schema.String, + }), + Schema.Struct({ + type: Schema.tag("function_call_output"), + call_id: Schema.String, + output: OpenResponsesFunctionCallOutput, + }), +]) +type OpenResponsesInputItem = Schema.Schema.Type + +// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold +// multiple streamed summary parts into the same item before flushing. +type OpenResponsesReasoningInput = { + type: "reasoning" + id: string + summary: Array<{ type: "summary_text"; text: string }> + encrypted_content?: string | null +} +type OpenResponsesReasoningReplay = Omit + +export const Tool = Schema.Struct({ + type: Schema.tag("function"), + name: Schema.String, + description: Schema.String, + parameters: JsonObject, + strict: Schema.optional(Schema.Boolean), +}) + +export const ToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), +]) + +// Fields shared between the HTTP body and the WebSocket `response.create` +// message. The HTTP body adds `stream: true`; the WebSocket message adds +// `type: "response.create"`. Defining the shared shape once keeps the two +// transports in sync without a destructure-and-strip dance. +export const coreFields = { + model: Schema.String, + input: Schema.Array(OpenResponsesInputItem), + instructions: Schema.optional(Schema.String), + tools: optionalArray(Tool), + tool_choice: Schema.optional(ToolChoice), + store: Schema.optional(Schema.Boolean), + service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema), + prompt_cache_key: Schema.optional(Schema.String), + include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema), + reasoning: Schema.optional( + Schema.Struct({ + effort: Schema.optional(OpenResponsesOptions.ReasoningEffort), + summary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])), + }), + ), + text: Schema.optional( + Schema.Struct({ + verbosity: Schema.optional(OpenResponsesOptions.TextVerbositySchema), + }), + ), + max_output_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), +} + +const OpenResponsesBody = Schema.Struct({ + ...coreFields, + stream: Schema.Literal(true), +}) +export type OpenResponsesBody = Schema.Schema.Type + +const OpenResponsesUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })), + output_tokens: Schema.optional(Schema.Number), + output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), + total_tokens: Schema.optional(Schema.Number), +}) +type OpenResponsesUsage = Schema.Schema.Type + +export const StreamItem = Schema.StructWithRest( + Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + call_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + arguments: Schema.optional(Schema.String), + encrypted_content: optionalNull(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +export type StreamItem = Schema.Schema.Type + +// The Responses schema puts streaming error details at the top level and +// response failures under `response.error`. WebSocket failures use an +// event-level `error` envelope, so accept all three shapes here. +// https://www.openresponses.org/specification +const OpenResponsesErrorPayload = Schema.Struct({ + code: optionalNull(Schema.String), + message: optionalNull(Schema.String), + param: optionalNull(Schema.String), +}) + +export const Event = Schema.StructWithRest( + Schema.Struct({ + type: Schema.String, + delta: Schema.optional(Schema.String), + item_id: Schema.optional(Schema.String), + summary_index: Schema.optional(Schema.Number), + item: Schema.optional(StreamItem), + response: Schema.optional( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.optional(Schema.String), + service_tier: optionalNull(Schema.String), + incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })), + usage: optionalNull(OpenResponsesUsage), + error: optionalNull(OpenResponsesErrorPayload), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + code: optionalNull(Schema.String), + message: Schema.optional(Schema.String), + param: optionalNull(Schema.String), + error: optionalNull(OpenResponsesErrorPayload), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +export type Event = Schema.Schema.Type + +export interface Extension { + readonly id: string + readonly name: string + readonly lowerMedia?: (input: { + readonly part: MediaPart + readonly media: ProviderShared.ValidatedMedia + readonly request: LLMRequest + }) => MediaInput | undefined +} + +const BASE: Extension = { id: ADAPTER, name: NAME } + +export interface ParserState { + readonly id: string + readonly name: string + readonly providerMetadataKey: string + readonly tools: ToolStream.State + readonly hasFunctionCall: boolean + readonly lifecycle: Lifecycle.State + readonly reasoningItems: Readonly> + readonly store: boolean | undefined +} + +type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded" + +interface ReasoningStreamItem { + readonly encryptedContent: string | null | undefined + // Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to + // strings, but typing the map as `Record` documents intent + // and matches the wire field. + readonly summaryParts: Readonly> +} + +// ============================================================================= +// Request Lowering +// ============================================================================= +export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* ( + protocolName: string, + tool: ToolDefinition, + inputSchema: JsonSchema, +) { + if (tool.native !== undefined) + return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`) + return { + type: "function" as const, + name: tool.name, + description: tool.description, + parameters: ToolSchemaProjection.responses(inputSchema), + // TODO: Read this from Responses tool options so direct LLM callers can opt into strict schemas. + strict: false, + } +}) + +export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable) => + ProviderShared.matchToolChoice(protocolName, toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (toolName) => ({ type: "function" as const, name: toolName }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({ + type: "function_call", + call_id: part.id, + name: part.name, + arguments: ProviderShared.encodeJson(part.input), +}) + +const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => { + const metadata = part.providerMetadata?.[providerMetadataKey] + if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0) + return undefined + const encryptedContent = + typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null + ? metadata.reasoningEncryptedContent + : undefined + return { + type: "reasoning", + id: metadata.itemId, + summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], + encrypted_content: encryptedContent, + } +} + +const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => { + const metadata = part.providerMetadata?.[providerMetadataKey] + return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0 + ? metadata.itemId + : undefined +} + +const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* ( + part: MediaPart, + request: LLMRequest, + extension: Extension, +) { + const media = yield* ProviderShared.validateMedia(extension.name, part, MEDIA_MIMES) + const extended = extension.lowerMedia?.({ part, media, request }) + if (extended) return extended + if (media.mime === "application/pdf") { + return { + type: "input_file" as const, + filename: part.filename ?? "document.pdf", + file_data: media.dataUrl, + } + } + return { type: "input_image" as const, image_url: media.dataUrl } +}) + +const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* ( + part: LLMRequest["messages"][number]["content"][number], + request: LLMRequest, + extension: Extension, +) { + if (part.type === "text") return { type: "input_text" as const, text: part.text } + if (part.type === "media") return yield* lowerMedia(part, request, extension) + return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"]) +}) + +// Tool results may carry structured text, images, and files. Keep media as provider-native +// content instead of JSON-stringifying base64 into a prompt string. +const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* ( + item: ToolContent, + request: LLMRequest, + extension: Extension, +) { + if (item.type === "text") return { type: "input_text" as const, text: item.text } + return yield* lowerMedia( + { type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, + request, + extension, + ) +}) + +const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(function* ( + part: ToolResultPart, + request: LLMRequest, + extension: Extension, +) { + // Text/json/error results are encoded as a plain string for backward + // compatibility with existing cassettes and provider expectations. + if (part.result.type !== "content") return ProviderShared.toolResultText(part) + // Preserve the narrowed array element type when compiled through a consumer package. + const content: ReadonlyArray = part.result.value + return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)) +}) + +const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) { + const system: OpenResponsesInputItem[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + const input: OpenResponsesInputItem[] = [...system] + const store = OpenResponsesOptions.resolve(request).store + const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses" + + for (const message of request.messages) { + if (message.role === "system") { + const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message) + const previous = input.at(-1) + if (previous && "role" in previous && previous.role === "user") + input[input.length - 1] = { + role: "user", + content: [...previous.content, { type: "input_text", text: part.text }], + } + else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] }) + continue + } + + if (message.role === "user") { + input.push({ + role: "user", + content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)), + }) + continue + } + + if (message.role === "assistant") { + const content: TextPart[] = [] + const reasoningItems: Record = {} + const reasoningReferences = new Set() + const hostedToolReferences = new Set() + const flushText = () => { + if (content.length === 0) return + input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) + content.splice(0, content.length) + } + for (const part of message.content) { + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "reasoning") { + flushText() + const reasoning = lowerReasoning(part, providerMetadataKey) + if (!reasoning) continue + if (store !== false) { + if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) + reasoningReferences.add(reasoning.id) + continue + } + const existing = reasoningItems[reasoning.id] + if (existing) { + existing.summary.push(...reasoning.summary) + if (typeof reasoning.encrypted_content === "string") + existing.encrypted_content = reasoning.encrypted_content + continue + } + const replay = { + type: reasoning.type, + summary: reasoning.summary, + encrypted_content: reasoning.encrypted_content, + } + reasoningItems[reasoning.id] = replay + input.push(replay) + continue + } + if (part.type === "tool-call") { + flushText() + if (part.providerExecuted === true) continue + input.push(lowerToolCall(part)) + continue + } + if (part.type === "tool-result" && part.providerExecuted === true) { + flushText() + const itemID = hostedToolItemID(part, providerMetadataKey) + if (store !== false && itemID && !hostedToolReferences.has(itemID)) + input.push({ type: "item_reference", id: itemID }) + if (store === false && part.result.type === "content") { + const content: ReadonlyArray = part.result.value + input.push({ + role: "user", + content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)), + }) + } + if (itemID) hostedToolReferences.add(itemID) + continue + } + return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [ + "text", + "reasoning", + "tool-call", + "tool-result", + ]) + } + flushText() + continue + } + + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"]) + input.push({ + type: "function_call_output", + call_id: part.id, + output: yield* lowerToolResultOutput(part, request, extension), + }) + } + } + + // With store:false, Responses APIs only accept previous reasoning items when the + // complete item has encrypted state. Summary blocks for one item may carry + // that state only on the last block, so filter after they have been joined. + return store === false + ? input.filter( + (item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string", + ) + : input +}) + +const lowerOptions = (request: LLMRequest) => { + const options = OpenResponsesOptions.resolve(request) + return { + ...(options.instructions ? { instructions: options.instructions } : {}), + ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}), + ...(options.include ? { include: options.include } : {}), + ...(options.reasoningEffort || options.reasoningSummary + ? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } } + : {}), + ...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}), + ...(options.serviceTier ? { service_tier: options.serviceTier } : {}), + } +} + +export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* ( + request: LLMRequest, + extension: Extension = BASE, +) { + const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + return { + model: request.model.id, + input: yield* lowerMessages(request, extension), + tools: + request.tools.length === 0 + ? undefined + : yield* Effect.forEach(request.tools, (tool) => + lowerTool( + extension.name, + tool, + ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), + ), + ), + tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined, + stream: true as const, + max_output_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + ...lowerOptions(request), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// Responses APIs report `input_tokens` (inclusive total) with a +// `cached_tokens` subset, and `output_tokens` (inclusive total) with a +// `reasoning_tokens` subset. Pass the totals through and derive the +// non-cached breakdown. +const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => { + if (!usage) return undefined + const cached = usage.input_tokens_details?.cached_tokens + const reasoning = usage.output_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) + return new Usage({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), + providerMetadata: { [providerMetadataKey]: usage }, + }) +} + +const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason => { + const reason = event.response?.incomplete_details?.reason + if (reason === undefined || reason === null) { + if (hasFunctionCall) return "tool-calls" + if (event.type === "response.incomplete") return "unknown" + return "stop" + } + if (reason === "max_output_tokens") return "length" + if (reason === "content_filter") return "content-filter" + return hasFunctionCall ? "tool-calls" : "unknown" +} + +export const providerMetadata = (state: ParserState, metadata: Record): ProviderMetadata => ({ + [state.providerMetadataKey]: metadata, +}) + +const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } => + item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0 + +export type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +// `response.completed` / `response.incomplete` are clean finishes that emit a +// `finish` event; `response.failed` is a hard failure. All three end the stream, +// so keep this set aligned with `step` and the protocol's terminal predicate. +const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) +export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type) + +const onOutputTextDelta = (state: ParserState, event: Event): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + events, + ] +} + +const onOutputTextDone = (state: ParserState, event: Event): StepResult => { + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events] +} + +export const onReasoningDelta = (state: ParserState, event: Event): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + const itemID = event.item_id ?? "reasoning-0" + const id = + event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID + return [ + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), + }, + events, + ] +} + +export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS] + +const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) => + providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }) + +// Responses APIs stream reasoning items in a stable order: +// `output_item.added` (reasoning) → +// `reasoning_summary_part.added` (index=0) → +// `reasoning_summary_text.delta` → +// `reasoning_summary_part.done` (index=0) → +// (repeat for index>0) → +// `output_item.done` (reasoning). +// The handlers below rely on this ordering: `onOutputItemAdded` seeds the +// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0` +// short-circuits when the entry already exists, and higher-index handlers +// fold against the same entry. Behaviour for out-of-order events is +// best-effort, not guaranteed. +const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { + const item = event.item + if (item && isReasoningItem(item)) { + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)), + reasoningItems: { + ...state.reasoningItems, + [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, + }, + }, + events, + ] + } + if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + const metadata = providerMetadata(state, { itemId: item.id }) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + return [ + { + ...state, + lifecycle, + tools: ToolStream.start(state.tools, item.id, { + id: item.call_id ?? item.id, + name: item.name ?? "", + input: item.arguments ?? "", + providerMetadata: metadata, + }), + }, + [ + ...events, + LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }), + ], + ] +} + +const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => { + if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] + const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} } + if (event.summary_index === 0) { + if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + state.lifecycle, + events, + `${event.item_id}:0`, + providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }), + ), + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { ...item, summaryParts: { 0: "active" } }, + }, + }, + events, + ] + } + + const events: LLMEvent[] = [] + const closed = Object.entries(item.summaryParts) + .filter((entry) => entry[1] === "can-conclude") + .reduce( + (lifecycle, entry) => + Lifecycle.reasoningEnd( + lifecycle, + events, + `${event.item_id}:${entry[0]}`, + providerMetadata(state, { itemId: event.item_id }), + ), + state.lifecycle, + ) + return [ + { + ...state, + lifecycle: Lifecycle.reasoningStart( + closed, + events, + `${event.item_id}:${event.summary_index}`, + providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }), + ), + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { + ...item, + summaryParts: { + ...Object.fromEntries( + Object.entries(item.summaryParts).map((entry) => + entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry, + ), + ), + [event.summary_index]: "active", + }, + }, + }, + }, + events, + ] +} + +const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => { + if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] + const item = state.reasoningItems[event.item_id] + if (!item) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...state, + lifecycle: + state.store !== false + ? Lifecycle.reasoningEnd( + state.lifecycle, + events, + `${event.item_id}:${event.summary_index}`, + providerMetadata(state, { itemId: event.item_id }), + ) + : state.lifecycle, + reasoningItems: { + ...state.reasoningItems, + [event.item_id]: { + ...item, + summaryParts: { + ...item.summaryParts, + [event.summary_index]: state.store !== false ? "concluded" : "can-conclude", + }, + }, + }, + }, + events, + ] +} + +const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* ( + state: ParserState, + event: Event, +) { + if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + state.id, + state.tools, + event.item_id, + event.delta, + `${state.name} tool argument delta is missing its tool call`, + ) + if (ToolStream.isError(result)) return yield* result + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult +}) + +const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) { + const item = event.item + if (!item) return [state, NO_EVENTS] satisfies StepResult + + if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id }) + + if (item.type === "function_call") { + if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult + const tools = state.tools[item.id] + ? state.tools + : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) + const result = + item.arguments === undefined + ? yield* ToolStream.finish(state.id, tools, item.id) + : yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...resultEvents) + return [ + { + ...state, + lifecycle, + hasFunctionCall: + resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) || + state.hasFunctionCall, + tools: result.tools, + }, + events, + ] satisfies StepResult + } + + if (isReasoningItem(item)) { + const events: LLMEvent[] = [] + const metadata = reasoningMetadata(state, item) + const reasoningItem = state.reasoningItems[item.id] + if (reasoningItem) { + const lifecycle = Object.entries(reasoningItem.summaryParts) + .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude") + .reduce( + (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata), + state.lifecycle, + ) + const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems + return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult + } + if (!state.lifecycle.reasoning.has(item.id)) { + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata })) + events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata })) + return [{ ...state, lifecycle }, events] satisfies StepResult + } + return [ + { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) }, + events, + ] satisfies StepResult + } + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onResponseFinish = (state: ParserState, event: Event): StepResult => { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: { + normalized: mapFinishReason(event, state.hasFunctionCall), + raw: event.response?.incomplete_details?.reason, + }, + usage: mapUsage(event.response?.usage, state.providerMetadataKey), + providerMetadata: + event.response?.id || event.response?.service_tier + ? providerMetadata(state, { + responseId: event.response.id, + serviceTier: event.response.service_tier, + }) + : undefined, + }) + return [{ ...state, lifecycle }, events] +} + +// Build a single human-readable message from whatever the provider supplied. +// When both code and message are present, prefix the code so consumers see +// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just +// the bare message — production rate limits and context-length failures used +// to be indistinguishable from generic stream drops. +const providerErrorMessage = (event: Event, fallback: string): string => { + const nested = event.error ?? event.response?.error ?? undefined + const message = event.message || nested?.message || undefined + const code = event.code || nested?.code || undefined + if (message && code) return `${code}: ${message}` + return message || code || fallback +} + +const providerError = (state: ParserState, event: Event, fallback: string) => { + const code = event.code || event.error?.code || event.response?.error?.code || undefined + const message = providerErrorMessage(event, fallback) + return new LLMError({ + module: state.id, + method: "stream", + reason: classifyProviderFailure({ message, code }), + }) +} + +export const step = (state: ParserState, event: Event) => { + if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) + if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) + if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") + return Effect.succeed(onReasoningDelta(state, event)) + if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") + return Effect.succeed(onReasoningDone(state, event)) + if (event.type === "response.reasoning_summary_part.added") + return Effect.succeed(onReasoningSummaryPartAdded(state, event)) + if (event.type === "response.reasoning_summary_part.done") + return Effect.succeed(onReasoningSummaryPartDone(state, event)) + if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) + if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) + if (event.type === "response.output_item.done") return onOutputItemDone(state, event) + if (event.type === "response.completed" || event.type === "response.incomplete") + return Effect.succeed(onResponseFinish(state, event)) + if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`) + if (event.type === "error") return providerError(state, event, `${state.name} stream error`) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol +// ============================================================================= +/** + * The provider-neutral Open Responses protocol. Provider-specific Responses + * implementations compose this baseline with their own tools and event variants. + */ +export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({ + id: extension.id, + name: extension.name, + providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses", + hasFunctionCall: false, + tools: ToolStream.empty(), + lifecycle: Lifecycle.initial(), + reasoningItems: {}, + store: OpenResponsesOptions.resolve(request).store, +}) + +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenResponsesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(Event), + initial, + step, + terminal, + }, +}) + +export const httpTransport = HttpTransport.sseJson.with() + +export * as OpenResponses from "./open-responses" diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index f0f5dbfb6b5..0e327f2abf2 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -396,14 +396,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: return messages }) -const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) { - const store = OpenAIOptions.store(request) - const reasoningEffort = OpenAIOptions.reasoningEffort(request) +const lowerOptions = (request: LLMRequest) => { + const options = OpenAIOptions.resolve(request) return { - ...(store !== undefined ? { store } : {}), - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}), } -}) +} const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { // `fromRequest` returns the provider body only. Endpoint, auth, framing, @@ -434,7 +433,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR presence_penalty: generation?.presencePenalty, seed: generation?.seed, stop: generation?.stop, - ...(yield* lowerOptions(request)), + ...lowerOptions(request), } }) diff --git a/packages/ai/src/protocols/openai-compatible-responses.ts b/packages/ai/src/protocols/openai-compatible-responses.ts index 2c56aadafae..0278c7941e4 100644 --- a/packages/ai/src/protocols/openai-compatible-responses.ts +++ b/packages/ai/src/protocols/openai-compatible-responses.ts @@ -1,23 +1,22 @@ import { Route, type RouteRoutedModelInput } from "../route/client" import { Endpoint } from "../route/endpoint" -import { OpenAIResponses } from "./openai-responses" +import { OpenResponses } from "./open-responses" const ADAPTER = "openai-compatible-responses" export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput /** - * Route for providers that expose an OpenAI Responses-compatible `/responses` - * endpoint. Provider helpers configure identity, endpoint, and auth before - * model selection while this route reuses the OpenAI Responses protocol. + * Deployment adapter for providers that expose an Open Responses-compatible + * `/responses` endpoint. Provider helpers configure identity, endpoint, and + * auth while the semantic protocol remains provider-neutral. */ export const route = Route.make({ id: ADAPTER, - providerMetadataKey: "openai", - protocol: OpenAIResponses.protocol, - endpoint: Endpoint.path(OpenAIResponses.PATH), - transport: OpenAIResponses.httpTransport, - defaults: { providerOptions: { openai: { store: false } } }, + providerMetadataKey: "openresponses", + protocol: OpenResponses.protocol, + endpoint: Endpoint.path(OpenResponses.PATH), + transport: OpenResponses.httpTransport, }) export * as OpenAICompatibleResponses from "./openai-compatible-responses" diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 53fc62d3a0b..f8655c67294 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -2,134 +2,20 @@ import { Effect, Encoding, Schema } from "effect" import { Route } from "../route/client" import { Auth } from "../route/auth" import { Endpoint } from "../route/endpoint" -import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" -import { - LLMError, - LLMEvent, - Usage, - type FinishReason, - type JsonSchema, - type LLMRequest, - type MediaPart, - type ProviderMetadata, - type ReasoningPart, - type TextPart, - type ToolCallPart, - type ToolDefinition, - type ToolContent, - type ToolResultPart, -} from "../schema" -import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" -import { classifyProviderFailure } from "../provider-error" -import { OpenAIOptions } from "./utils/openai-options" +import { HttpTransport, WebSocketTransport } from "../route/transport" +import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema" +import { OpenResponses } from "./open-responses" +import { optionalArray, ProviderShared } from "./shared" import { Lifecycle } from "./utils/lifecycle" -import { ToolSchemaProjection } from "./utils/tool-schema" -import { ToolStream } from "./utils/tool-stream" import { OpenAIImage } from "./utils/openai-image" +import { ToolSchemaProjection } from "./utils/tool-schema" const ADAPTER = "openai-responses" -const MEDIA_MIMES = new Set([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES]) +const NAME = "OpenAI Responses" export const DEFAULT_BASE_URL = "https://api.openai.com/v1" -export const PATH = "/responses" +export const PATH = OpenResponses.PATH -// ============================================================================= -// Request Body Schema -// ============================================================================= -const OpenAIResponsesInputText = Schema.Struct({ - type: Schema.tag("input_text"), - text: Schema.String, -}) -const OpenAIResponsesInputImage = Schema.Struct({ - type: Schema.tag("input_image"), - image_url: Schema.String, -}) -const OpenAIResponsesInputFile = Schema.Struct({ - type: Schema.tag("input_file"), - filename: Schema.String, - file_data: Schema.String, - mime_type: Schema.optional(Schema.String), -}) -const OpenAIResponsesInputContent = Schema.Union([ - OpenAIResponsesInputText, - OpenAIResponsesInputImage, - OpenAIResponsesInputFile, -]) -type OpenAIResponsesInputContent = Schema.Schema.Type - -const OpenAIResponsesOutputText = Schema.Struct({ - type: Schema.tag("output_text"), - text: Schema.String, -}) - -const OpenAIResponsesReasoningSummaryText = Schema.Struct({ - type: Schema.tag("summary_text"), - text: Schema.String, -}) - -const OpenAIResponsesReasoningItem = Schema.Struct({ - type: Schema.tag("reasoning"), - id: Schema.optionalKey(Schema.String), - summary: Schema.Array(OpenAIResponsesReasoningSummaryText), - encrypted_content: optionalNull(Schema.String), -}) - -const OpenAIResponsesItemReference = Schema.Struct({ - type: Schema.tag("item_reference"), - id: Schema.String, -}) - -// `function_call_output.output` accepts either a plain string or an ordered -// array of content items so tools can return images and files in addition to text. -// https://platform.openai.com/docs/api-reference/responses/object -const OpenAIResponsesFunctionCallOutputContent = Schema.Union([ - OpenAIResponsesInputText, - OpenAIResponsesInputImage, - OpenAIResponsesInputFile, -]) - -const OpenAIResponsesFunctionCallOutput = Schema.Union([ - Schema.String, - Schema.Array(OpenAIResponsesFunctionCallOutputContent), -]) - -const OpenAIResponsesInputItem = Schema.Union([ - Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), - Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), - Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), - OpenAIResponsesReasoningItem, - OpenAIResponsesItemReference, - Schema.Struct({ - type: Schema.tag("function_call"), - call_id: Schema.String, - name: Schema.String, - arguments: Schema.String, - }), - Schema.Struct({ - type: Schema.tag("function_call_output"), - call_id: Schema.String, - output: OpenAIResponsesFunctionCallOutput, - }), -]) -type OpenAIResponsesInputItem = Schema.Schema.Type - -// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold -// multiple streamed summary parts into the same item before flushing. -type OpenAIResponsesReasoningInput = { - type: "reasoning" - id: string - summary: Array<{ type: "summary_text"; text: string }> - encrypted_content?: string | null -} -type OpenAIResponsesReasoningReplay = Omit - -const OpenAIResponsesTool = Schema.Struct({ - type: Schema.tag("function"), - name: Schema.String, - description: Schema.String, - parameters: JsonObject, - strict: Schema.optional(Schema.Boolean), -}) const OpenAIResponsesImageGenerationTool = Schema.Struct({ type: Schema.tag("image_generation"), action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])), @@ -141,43 +27,18 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({ quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])), size: Schema.optional(OpenAIImage.Size), }) -const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool]) -type OpenAIResponsesTool = Schema.Schema.Type + +const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool]) const OpenAIResponsesToolChoice = Schema.Union([ - Schema.Literals(["auto", "none", "required"]), - Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), + OpenResponses.ToolChoice, Schema.Struct({ type: Schema.tag("image_generation") }), ]) -// Fields shared between the HTTP body and the WebSocket `response.create` -// message. The HTTP body adds `stream: true`; the WebSocket message adds -// `type: "response.create"`. Defining the shared shape once keeps the two -// transports in sync without a destructure-and-strip dance. const OpenAIResponsesCoreFields = { - model: Schema.String, - input: Schema.Array(OpenAIResponsesInputItem), - instructions: Schema.optional(Schema.String), + ...OpenResponses.coreFields, tools: optionalArray(OpenAIResponsesTools), tool_choice: Schema.optional(OpenAIResponsesToolChoice), - store: Schema.optional(Schema.Boolean), - service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier), - prompt_cache_key: Schema.optional(Schema.String), - include: optionalArray(OpenAIOptions.OpenAIResponseIncludable), - reasoning: Schema.optional( - Schema.Struct({ - effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), - summary: Schema.optional(Schema.Literal("auto")), - }), - ), - text: Schema.optional( - Schema.Struct({ - verbosity: Schema.optional(OpenAIOptions.OpenAITextVerbosity), - }), - ), - max_output_tokens: Schema.optional(Schema.Number), - temperature: Schema.optional(Schema.Number), - top_p: Schema.optional(Schema.Number), } const OpenAIResponsesBody = Schema.Struct({ @@ -196,100 +57,20 @@ const OpenAIResponsesWebSocketMessage = Schema.StructWithRest( type OpenAIResponsesWebSocketMessage = Schema.Schema.Type const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage)) -const OpenAIResponsesUsage = Schema.Struct({ - input_tokens: Schema.optional(Schema.Number), - input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })), - output_tokens: Schema.optional(Schema.Number), - output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), - total_tokens: Schema.optional(Schema.Number), -}) -type OpenAIResponsesUsage = Schema.Schema.Type +const extension = { + id: ADAPTER, + name: NAME, + lowerMedia: ({ part, media, request }) => { + if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined + return { + type: "input_file", + filename: part.filename ?? "document.pdf", + file_data: media.base64, + mime_type: media.mime, + } + }, +} satisfies OpenResponses.Extension -const OpenAIResponsesStreamItem = Schema.Struct({ - type: Schema.String, - id: Schema.optional(Schema.String), - call_id: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), - arguments: Schema.optional(Schema.String), - // Hosted (provider-executed) tool fields. Each hosted tool item carries its - // own subset of these — we capture them generically so we can surface the - // call's typed input portion and round-trip the full result payload without - // hand-rolling a per-tool schema. - status: Schema.optional(Schema.String), - action: Schema.optional(Schema.Unknown), - queries: Schema.optional(Schema.Unknown), - results: Schema.optional(Schema.Unknown), - code: Schema.optional(Schema.String), - container_id: Schema.optional(Schema.String), - outputs: Schema.optional(Schema.Unknown), - server_label: Schema.optional(Schema.String), - output: Schema.optional(Schema.Unknown), - result: Schema.optional(Schema.String), - output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])), - error: Schema.optional(Schema.Unknown), - encrypted_content: optionalNull(Schema.String), -}) -type OpenAIResponsesStreamItem = Schema.Schema.Type - -// The Responses schema puts streaming error details at the top level and -// response failures under `response.error`. The official SDK also recognizes -// an event-level HTTP-style `error` envelope, so accept all three shapes here. -// https://github.com/openai/openai-openapi/blob/5162af98d3147432c14680df789e8e12d4891e6b/openapi.yaml#L67234-L67382 -// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/core/streaming.ts#L58-L85 -const OpenAIResponsesErrorPayload = Schema.Struct({ - code: optionalNull(Schema.String), - message: optionalNull(Schema.String), - param: optionalNull(Schema.String), -}) - -const OpenAIResponsesEvent = Schema.Struct({ - type: Schema.String, - delta: Schema.optional(Schema.String), - item_id: Schema.optional(Schema.String), - summary_index: Schema.optional(Schema.Number), - item: Schema.optional(OpenAIResponsesStreamItem), - response: Schema.optional( - Schema.StructWithRest( - Schema.Struct({ - id: Schema.optional(Schema.String), - service_tier: optionalNull(Schema.String), - incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })), - usage: optionalNull(OpenAIResponsesUsage), - error: optionalNull(OpenAIResponsesErrorPayload), - }), - [Schema.Record(Schema.String, Schema.Unknown)], - ), - ), - code: optionalNull(Schema.String), - message: Schema.optional(Schema.String), - param: optionalNull(Schema.String), - error: optionalNull(OpenAIResponsesErrorPayload), -}) -type OpenAIResponsesEvent = Schema.Schema.Type - -interface ParserState { - readonly tools: ToolStream.State - readonly hasFunctionCall: boolean - readonly lifecycle: Lifecycle.State - readonly reasoningItems: Readonly> - readonly store: boolean | undefined -} - -type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded" - -interface ReasoningStreamItem { - readonly encryptedContent: string | null | undefined - // Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to - // strings, but typing the map as `Record` documents intent - // and matches the wire field. - readonly summaryParts: Readonly> -} - -const invalid = ProviderShared.invalidRequest - -// ============================================================================= -// Request Lowering -// ============================================================================= const nativeImageToolInput = (tool: ToolDefinition) => { const native = tool.native?.openai return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined @@ -304,20 +85,13 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe const native = nativeImageToolInput(tool) if (native !== undefined) { if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native - return yield* invalid("OpenAI Responses image generation tool options are invalid") - } - return { - type: "function" as const, - name: tool.name, - description: tool.description, - parameters: ToolSchemaProjection.openAI(inputSchema), - // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. - strict: false, + return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid") } + return yield* OpenResponses.lowerTool(NAME, tool, inputSchema) }) const lowerToolChoice = (toolChoice: NonNullable, tools: ReadonlyArray) => - ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, { + ProviderShared.matchToolChoice(NAME, toolChoice, { auto: () => "auto" as const, none: () => "none" as const, required: () => "required" as const, @@ -327,241 +101,14 @@ const lowerToolChoice = (toolChoice: NonNullable, tool : { type: "function" as const, name }, }) -const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ - type: "function_call", - call_id: part.id, - name: part.name, - arguments: ProviderShared.encodeJson(part.input), -}) - -const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => { - const openai = part.providerMetadata?.openai - if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0) - return undefined - const encryptedContent = - typeof openai.reasoningEncryptedContent === "string" - ? openai.reasoningEncryptedContent - : openai.reasoningEncryptedContent === null - ? null - : undefined - return { - type: "reasoning", - id: openai.itemId, - summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], - encrypted_content: encryptedContent, - } -} - -const hostedToolItemID = (part: ToolResultPart) => { - const openai = part.providerMetadata?.openai - return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0 - ? openai.itemId - : undefined -} - -const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) { - const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES) - if (media.mime === "application/pdf") { - // xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data. - if (provider === "xai") - return { - type: "input_file" as const, - filename: part.filename ?? "document.pdf", - file_data: media.base64, - mime_type: media.mime, - } - return { - type: "input_file" as const, - filename: part.filename ?? "document.pdf", - file_data: media.dataUrl, - } - } - return { type: "input_image" as const, image_url: media.dataUrl } -}) - -const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ( - part: LLMRequest["messages"][number]["content"][number], - provider: string, -) { - if (part.type === "text") return { type: "input_text" as const, text: part.text } - if (part.type === "media") return yield* lowerMedia(part, provider) - return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) -}) - -// Tool results may carry structured text, images, and files. Keep media as provider-native -// content instead of JSON-stringifying base64 into a prompt string. -const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* ( - item: ToolContent, - provider: string, -) { - if (item.type === "text") return { type: "input_text" as const, text: item.text } - return yield* lowerMedia( - { type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, - provider, - ) -}) - -const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* ( - part: ToolResultPart, - provider: string, -) { - // Text/json/error results are encoded as a plain string for backward - // compatibility with existing cassettes and provider expectations. - if (part.result.type !== "content") return ProviderShared.toolResultText(part) - // Preserve the narrowed array element type when compiled through a consumer package. - const content: ReadonlyArray = part.result.value - return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider)) -}) - -const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { - const system: OpenAIResponsesInputItem[] = - request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] - const input: OpenAIResponsesInputItem[] = [...system] - const store = OpenAIOptions.store(request) - - for (const message of request.messages) { - if (message.role === "system") { - const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message) - const previous = input.at(-1) - if (previous && "role" in previous && previous.role === "user") - input[input.length - 1] = { - role: "user", - content: [...previous.content, { type: "input_text", text: part.text }], - } - else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] }) - continue - } - - if (message.role === "user") { - input.push({ - role: "user", - content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)), - }) - continue - } - - if (message.role === "assistant") { - const content: TextPart[] = [] - const reasoningItems: Record = {} - const reasoningReferences = new Set() - const hostedToolReferences = new Set() - const flushText = () => { - if (content.length === 0) return - input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) - content.splice(0, content.length) - } - for (const part of message.content) { - if (part.type === "text") { - content.push(part) - continue - } - if (part.type === "reasoning") { - flushText() - const reasoning = lowerReasoning(part) - if (!reasoning) continue - if (store !== false) { - if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) - reasoningReferences.add(reasoning.id) - continue - } - const existing = reasoningItems[reasoning.id] - if (existing) { - existing.summary.push(...reasoning.summary) - if (typeof reasoning.encrypted_content === "string") - existing.encrypted_content = reasoning.encrypted_content - continue - } - const replay = { - type: reasoning.type, - summary: reasoning.summary, - encrypted_content: reasoning.encrypted_content, - } - reasoningItems[reasoning.id] = replay - input.push(replay) - continue - } - if (part.type === "tool-call") { - flushText() - if (part.providerExecuted === true) continue - input.push(lowerToolCall(part)) - continue - } - if (part.type === "tool-result" && part.providerExecuted === true) { - flushText() - const itemID = hostedToolItemID(part) - if (store !== false && itemID && !hostedToolReferences.has(itemID)) - input.push({ type: "item_reference", id: itemID }) - if (store === false && part.name === "image_generation" && part.result.type === "content") { - const content: ReadonlyArray = part.result.value - input.push({ - role: "user", - content: yield* Effect.forEach(content, (item) => - lowerToolResultContentItem(item, request.model.provider), - ), - }) - } - if (itemID) hostedToolReferences.add(itemID) - continue - } - return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", [ - "text", - "reasoning", - "tool-call", - "tool-result", - ]) - } - flushText() - continue - } - - for (const part of message.content) { - if (!ProviderShared.supportsContent(part, ["tool-result"])) - return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"]) - input.push({ - type: "function_call_output", - call_id: part.id, - output: yield* lowerToolResultOutput(part, request.model.provider), - }) - } - } - - // With store:false, OpenAI only accepts previous reasoning items when the - // complete item has encrypted state. Summary blocks for one item may carry - // that state only on the last block, so filter after they have been joined. - return store === false - ? input.filter( - (item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string", - ) - : input -}) - -const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) { - const store = OpenAIOptions.store(request) - const promptCacheKey = OpenAIOptions.promptCacheKey(request) - const effort = OpenAIOptions.reasoningEffort(request) - const summary = OpenAIOptions.reasoningSummary(request) - const include = OpenAIOptions.include(request) - const verbosity = OpenAIOptions.textVerbosity(request) - const instructions = OpenAIOptions.instructions(request) - const serviceTier = OpenAIOptions.serviceTier(request) - return { - ...(instructions ? { instructions } : {}), - ...(store !== undefined ? { store } : {}), - ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), - ...(include ? { include } : {}), - ...(effort || summary ? { reasoning: { effort, summary } } : {}), - ...(verbosity ? { text: { verbosity } } : {}), - ...(serviceTier ? { service_tier: serviceTier } : {}), - } -}) - const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { - const generation = request.generation - const options = yield* lowerOptions(request) + const body = yield* OpenResponses.fromRequest( + LLMRequest.update(request, { tools: [], toolChoice: undefined }), + extension, + ) const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { - model: request.model.id, - input: yield* lowerMessages(request), + ...body, tools: request.tools.length === 0 ? undefined @@ -569,58 +116,25 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined, - stream: true as const, - max_output_tokens: generation?.maxTokens, - temperature: generation?.temperature, - top_p: generation?.topP, - ...options, - } + } satisfies OpenAIResponsesBody }) -// ============================================================================= -// Stream Parsing -// ============================================================================= -// OpenAI Responses reports `input_tokens` (inclusive total) with a -// `cached_tokens` subset, and `output_tokens` (inclusive total) with a -// `reasoning_tokens` subset. Pass the totals through and derive the -// non-cached breakdown. -const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { - if (!usage) return undefined - const cached = usage.input_tokens_details?.cached_tokens - const reasoning = usage.output_tokens_details?.reasoning_tokens - const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) - return new Usage({ - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - nonCachedInputTokens: nonCached, - cacheReadInputTokens: cached, - reasoningTokens: reasoning, - totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), - providerMetadata: { openai: usage }, - }) +type HostedToolData = OpenResponses.StreamItem & { + readonly id: string + readonly status?: string + readonly action?: unknown + readonly queries?: unknown + readonly results?: unknown + readonly code?: string + readonly container_id?: string + readonly outputs?: unknown + readonly server_label?: string + readonly output?: unknown + readonly result?: string + readonly output_format?: "png" | "jpeg" | "webp" + readonly error?: unknown } -const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => { - const reason = event.response?.incomplete_details?.reason - if (reason === undefined || reason === null) - return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop" - if (reason === "max_output_tokens") return "length" - if (reason === "content_filter") return "content-filter" - return hasFunctionCall ? "tool-calls" : "unknown" -} - -const openaiMetadata = (metadata: Record): ProviderMetadata => ({ openai: metadata }) - -// Hosted tool items (provider-executed) ship their typed input + status + -// result fields all in one item. We expose them as a `tool-call` + -// `tool-result` pair so consumers can treat them uniformly with client tools, -// only differentiated by `providerExecuted: true`. -// -// One record per OpenAI Responses item type that represents a hosted -// (provider-executed) tool call: the common name we surface, plus an `input` -// extractor that picks the fields the model actually populated for that tool. -// Falling back to `{}` when an entry isn't fully typed keeps unknown tools -// observable without rolling a per-tool schema. const HOSTED_TOOLS = { web_search_call: { name: "web_search", input: (item) => item.action ?? {} }, web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} }, @@ -636,38 +150,28 @@ const HOSTED_TOOLS = { input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }), }, local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} }, -} as const satisfies Record< - string, - { readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown } -> +} as const satisfies Record unknown }> type HostedToolType = keyof typeof HOSTED_TOOLS +type HostedToolItem = HostedToolData & { readonly type: HostedToolType } -const isHostedToolItem = ( - item: OpenAIResponsesStreamItem, -): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } => +const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem => item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0 -const isReasoningItem = ( - item: OpenAIResponsesStreamItem, -): item is OpenAIResponsesStreamItem & { type: "reasoning"; id: string } => - item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0 - -// Round-trip the full item as the structured result so consumers can extract -// outputs / sources / status without re-decoding. -const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) { - const isError = typeof item.error !== "undefined" && item.error !== null +const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) { + const isError = item.error !== undefined && item.error !== null if (item.type === "image_generation_call" && item.result) { yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe( Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")), ) + const format = item.output_format ?? "png" return { type: "content" as const, value: [ { type: "file" as const, - uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`, - mime: `image/${item.output_format ?? "png"}`, + uri: `data:image/${format};base64,${item.result}`, + mime: `image/${format}`, }, ], } @@ -675,12 +179,15 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item } }) -const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* ( - item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string }, +const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* ( + state: OpenResponses.ParserState, + item: HostedToolItem, ) { const tool = HOSTED_TOOLS[item.type] - const providerMetadata = openaiMetadata({ itemId: item.id }) - return [ + const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id }) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push( LLMEvent.toolCall({ id: item.id, name: tool.name, @@ -695,366 +202,20 @@ const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* providerExecuted: true, providerMetadata, }), - ] + ) + return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult }) -type StepResult = readonly [ParserState, ReadonlyArray] - -const NO_EVENTS: StepResult["1"] = [] - -// `response.completed` / `response.incomplete` are clean finishes that emit a -// `finish` event; `response.failed` is a hard failure. All three end the stream, -// so keep this set aligned with `step` and the protocol's terminal predicate. -const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) - -const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (!event.delta) return [state, NO_EVENTS] - const events: LLMEvent[] = [] - return [ - { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, - events, - ] +const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => { + if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta") + return Effect.succeed(OpenResponses.onReasoningDelta(state, event)) + if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done") + return Effect.succeed(OpenResponses.onReasoningDone(state, event)) + if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item)) + return onHostedToolDone(state, event.item) + return OpenResponses.step(state, event) } -const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - const events: LLMEvent[] = [] - return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events] -} - -const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (!event.delta) return [state, NO_EVENTS] - const events: LLMEvent[] = [] - const itemID = event.item_id ?? "reasoning-0" - const id = - event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID - return [ - { - ...state, - lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), - }, - events, - ] -} - -const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS] - -const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => - openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }) - -// OpenAI Responses streams reasoning items in a stable order: -// `output_item.added` (reasoning) → -// `reasoning_summary_part.added` (index=0) → -// `reasoning_summary_text.delta` → -// `reasoning_summary_part.done` (index=0) → -// (repeat for index>0) → -// `output_item.done` (reasoning). -// The handlers below rely on this ordering: `onOutputItemAdded` seeds the -// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0` -// short-circuits when the entry already exists, and higher-index handlers -// fold against the same entry. Behaviour for out-of-order events is -// best-effort, not guaranteed. -const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - const item = event.item - if (item && isReasoningItem(item)) { - const events: LLMEvent[] = [] - return [ - { - ...state, - lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)), - reasoningItems: { - ...state.reasoningItems, - [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, - }, - }, - events, - ] - } - if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] - const providerMetadata = openaiMetadata({ itemId: item.id }) - const events: LLMEvent[] = [] - const lifecycle = Lifecycle.stepStart(state.lifecycle, events) - return [ - { - ...state, - lifecycle, - hasFunctionCall: state.hasFunctionCall, - tools: ToolStream.start(state.tools, item.id, { - id: item.call_id ?? item.id, - name: item.name ?? "", - input: item.arguments ?? "", - providerMetadata, - }), - }, - [...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })], - ] -} - -const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] - const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} } - if (event.summary_index === 0) { - if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS] - const events: LLMEvent[] = [] - return [ - { - ...state, - lifecycle: Lifecycle.reasoningStart( - state.lifecycle, - events, - `${event.item_id}:0`, - openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }), - ), - reasoningItems: { - ...state.reasoningItems, - [event.item_id]: { ...item, summaryParts: { 0: "active" } }, - }, - }, - events, - ] - } - - const events: LLMEvent[] = [] - const closed = Object.entries(item.summaryParts) - .filter((entry) => entry[1] === "can-conclude") - .reduce( - (lifecycle, entry) => - Lifecycle.reasoningEnd( - lifecycle, - events, - `${event.item_id}:${entry[0]}`, - openaiMetadata({ itemId: event.item_id }), - ), - state.lifecycle, - ) - return [ - { - ...state, - lifecycle: Lifecycle.reasoningStart( - closed, - events, - `${event.item_id}:${event.summary_index}`, - openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }), - ), - reasoningItems: { - ...state.reasoningItems, - [event.item_id]: { - ...item, - summaryParts: { - ...Object.fromEntries( - Object.entries(item.summaryParts).map((entry) => - entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry, - ), - ), - [event.summary_index]: "active", - }, - }, - }, - }, - events, - ] -} - -const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS] - const item = state.reasoningItems[event.item_id] - if (!item) return [state, NO_EVENTS] - const events: LLMEvent[] = [] - return [ - { - ...state, - lifecycle: - state.store !== false - ? Lifecycle.reasoningEnd( - state.lifecycle, - events, - `${event.item_id}:${event.summary_index}`, - openaiMetadata({ itemId: event.item_id }), - ) - : state.lifecycle, - reasoningItems: { - ...state.reasoningItems, - [event.item_id]: { - ...item, - summaryParts: { - ...item.summaryParts, - [event.summary_index]: state.store !== false ? "concluded" : "can-conclude", - }, - }, - }, - }, - events, - ] -} - -const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* ( - state: ParserState, - event: OpenAIResponsesEvent, -) { - if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult - const result = ToolStream.appendExisting( - ADAPTER, - state.tools, - event.item_id, - event.delta, - "OpenAI Responses tool argument delta is missing its tool call", - ) - if (ToolStream.isError(result)) return yield* result - const events: LLMEvent[] = [] - const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle - events.push(...result.events) - return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult -}) - -const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( - state: ParserState, - event: OpenAIResponsesEvent, -) { - const item = event.item - if (!item) return [state, NO_EVENTS] satisfies StepResult - - if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id }) - - if (item.type === "function_call") { - if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult - const tools = state.tools[item.id] - ? state.tools - : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) - const result = - item.arguments === undefined - ? yield* ToolStream.finish(ADAPTER, tools, item.id) - : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) - const events: LLMEvent[] = [] - const resultEvents = result.events ?? [] - const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle - events.push(...resultEvents) - return [ - { - ...state, - lifecycle, - hasFunctionCall: - resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) || - state.hasFunctionCall, - tools: result.tools, - }, - events, - ] satisfies StepResult - } - - if (isHostedToolItem(item)) { - const events: LLMEvent[] = [] - const lifecycle = Lifecycle.stepStart(state.lifecycle, events) - events.push(...(yield* hostedToolEvents(item))) - return [{ ...state, lifecycle }, events] satisfies StepResult - } - - if (isReasoningItem(item)) { - const events: LLMEvent[] = [] - const providerMetadata = reasoningMetadata(item) - const reasoningItem = state.reasoningItems[item.id] - if (reasoningItem) { - const lifecycle = Object.entries(reasoningItem.summaryParts) - .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude") - .reduce( - (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata), - state.lifecycle, - ) - const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems - return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult - } - if (!state.lifecycle.reasoning.has(item.id)) { - const lifecycle = Lifecycle.stepStart(state.lifecycle, events) - events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata })) - events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata })) - return [{ ...state, lifecycle }, events] satisfies StepResult - } - return [ - { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, providerMetadata) }, - events, - ] satisfies StepResult - } - - return [state, NO_EVENTS] satisfies StepResult -}) - -const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - const events: LLMEvent[] = [] - const lifecycle = Lifecycle.finish(state.lifecycle, events, { - reason: { - normalized: mapFinishReason(event, state.hasFunctionCall), - raw: event.response?.incomplete_details?.reason, - }, - usage: mapUsage(event.response?.usage), - providerMetadata: - event.response?.id || event.response?.service_tier - ? openaiMetadata({ - responseId: event.response.id, - serviceTier: event.response.service_tier, - }) - : undefined, - }) - return [{ ...state, lifecycle }, events] -} - -// Build a single human-readable message from whatever the provider supplied. -// When both code and message are present, prefix the code so consumers see -// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just -// the bare message — production rate limits and context-length failures used -// to be indistinguishable from generic stream drops. -const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => { - const nested = event.error ?? event.response?.error ?? undefined - const message = event.message || nested?.message || undefined - const code = event.code || nested?.code || undefined - if (message && code) return `${code}: ${message}` - return message || code || fallback -} - -const providerError = (event: OpenAIResponsesEvent, fallback: string) => { - const code = event.code || event.error?.code || event.response?.error?.code || undefined - const message = providerErrorMessage(event, fallback) - return new LLMError({ - module: ADAPTER, - method: "stream", - reason: classifyProviderFailure({ message, code }), - }) -} - -const step = (state: ParserState, event: OpenAIResponsesEvent) => { - if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) - if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) - if ( - event.type === "response.reasoning_text.delta" || - event.type === "response.reasoning_summary.delta" || - event.type === "response.reasoning_summary_text.delta" - ) - return Effect.succeed(onReasoningDelta(state, event)) - if ( - event.type === "response.reasoning_text.done" || - event.type === "response.reasoning_summary.done" || - event.type === "response.reasoning_summary_text.done" - ) - return Effect.succeed(onReasoningDone(state, event)) - if (event.type === "response.reasoning_summary_part.added") - return Effect.succeed(onReasoningSummaryPartAdded(state, event)) - if (event.type === "response.reasoning_summary_part.done") - return Effect.succeed(onReasoningSummaryPartDone(state, event)) - if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) - if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) - if (event.type === "response.output_item.done") return onOutputItemDone(state, event) - if (event.type === "response.completed" || event.type === "response.incomplete") - return Effect.succeed(onResponseFinish(state, event)) - if (event.type === "response.failed") return providerError(event, "OpenAI Responses response failed") - if (event.type === "error") return providerError(event, "OpenAI Responses stream error") - return Effect.succeed([state, NO_EVENTS]) -} - -// ============================================================================= -// Protocol And OpenAI Route -// ============================================================================= -/** - * The OpenAI Responses protocol — request body construction, body schema, and - * the streaming-event state machine. Used by native OpenAI and (once - * registered) Azure OpenAI Responses. - */ export const protocol = Protocol.make({ id: ADAPTER, body: { @@ -1062,16 +223,10 @@ export const protocol = Protocol.make({ from: fromRequest, }, stream: { - event: Protocol.jsonEvent(OpenAIResponsesEvent), - initial: (request) => ({ - hasFunctionCall: false, - tools: ToolStream.empty(), - lifecycle: Lifecycle.initial(), - reasoningItems: {}, - store: OpenAIOptions.store(request), - }), + event: OpenResponses.protocol.stream.event, + initial: (request) => OpenResponses.initial(request, extension), step, - terminal: (event) => TERMINAL_TYPES.has(event.type), + terminal: OpenResponses.terminal, }, }) diff --git a/packages/ai/src/protocols/utils/open-responses-options.ts b/packages/ai/src/protocols/utils/open-responses-options.ts new file mode 100644 index 00000000000..80288ca5cb1 --- /dev/null +++ b/packages/ai/src/protocols/utils/open-responses-options.ts @@ -0,0 +1,65 @@ +import { Schema } from "effect" +import { TextVerbosity, type LLMRequest } from "../../schema" + +export const ResponseIncludables = [ + "file_search_call.results", + "web_search_call.results", + "web_search_call.action.sources", + "message.input_image.image_url", + "computer_call_output.output.image_url", + "code_interpreter_call.outputs", + "reasoning.encrypted_content", + "message.output_text.logprobs", +] as const +export type ResponseIncludable = (typeof ResponseIncludables)[number] + +export const ServiceTiers = ["auto", "default", "flex", "priority"] as const +export type ServiceTier = (typeof ServiceTiers)[number] + +const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) +const INCLUDABLES = new Set(ResponseIncludables) +const SERVICE_TIERS = new Set(ServiceTiers) + +const isTextVerbosity = (value: unknown): value is Schema.Schema.Type => + typeof value === "string" && TEXT_VERBOSITY.has(value) + +const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value) + +export const ReasoningEffort = Schema.String +export const TextVerbositySchema = TextVerbosity +export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables) +export const ServiceTierSchema = Schema.Literals(ServiceTiers) + +export interface Resolved { + readonly instructions?: string + readonly store?: boolean + readonly promptCacheKey?: string + readonly reasoningEffort?: string + readonly reasoningSummary?: "auto" | "concise" | "detailed" + readonly include?: ReadonlyArray + readonly textVerbosity?: Schema.Schema.Type + readonly serviceTier?: ServiceTier +} + +export const resolve = (request: LLMRequest): Resolved => { + const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"] + const include = Array.isArray(input?.include) + ? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry)) + : [] + const reasoningSummary = input?.reasoningSummary + return { + instructions: typeof input?.instructions === "string" ? input.instructions : undefined, + store: typeof input?.store === "boolean" ? input.store : undefined, + promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined, + reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined, + reasoningSummary: + reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed" + ? reasoningSummary + : undefined, + include: include.length > 0 ? include : undefined, + textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined, + serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined, + } +} + +export * as OpenResponsesOptions from "./open-responses-options" diff --git a/packages/ai/src/protocols/utils/openai-options.ts b/packages/ai/src/protocols/utils/openai-options.ts index 5414923eda1..fc95e6a3623 100644 --- a/packages/ai/src/protocols/utils/openai-options.ts +++ b/packages/ai/src/protocols/utils/openai-options.ts @@ -1,85 +1,23 @@ -import { Schema } from "effect" -import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema" -import { ReasoningEfforts, TextVerbosity } from "../../schema" +import { ReasoningEfforts } from "../../schema" +import { OpenResponsesOptions } from "./open-responses-options" export const OpenAIReasoningEfforts = ReasoningEfforts export type OpenAIReasoningEffort = string // Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this // in lockstep with `openai-node/src/resources/responses/responses.ts`. -export const OpenAIResponseIncludables = [ - "file_search_call.results", - "web_search_call.results", - "web_search_call.action.sources", - "message.input_image.image_url", - "computer_call_output.output.image_url", - "code_interpreter_call.outputs", - "reasoning.encrypted_content", - "message.output_text.logprobs", -] as const -export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number] -export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const -export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] +export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables +export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable +export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers +export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier -const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) -const INCLUDABLES = new Set(OpenAIResponseIncludables) -const SERVICE_TIERS = new Set(OpenAIServiceTiers) - -export const OpenAIReasoningEffort = Schema.String -export const OpenAITextVerbosity = TextVerbosity -export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables) -export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers) +export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort +export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema +export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema +export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string" -const isTextVerbosity = (value: unknown): value is TextVerbosityValue => - typeof value === "string" && TEXT_VERBOSITY.has(value) - -const options = (request: LLMRequest) => request.providerOptions?.openai - -export const store = (request: LLMRequest): boolean | undefined => { - const value = options(request)?.store - return typeof value === "boolean" ? value : undefined -} - -export const reasoningEffort = (request: LLMRequest): string | undefined => { - const value = options(request)?.reasoningEffort - return typeof value === "string" ? value : undefined -} - -export const reasoningSummary = (request: LLMRequest): "auto" | undefined => - options(request)?.reasoningSummary === "auto" ? "auto" : undefined - -// Resolve the OpenAI Responses `include` field. Filters out unknown -// includable values defensively so a typo in upstream config drops the -// invalid entry instead of poisoning the wire body. An empty array (either -// passed directly or produced by filtering) is treated as "no include" and -// returns undefined so the request body omits the field entirely. -export const include = (request: LLMRequest): ReadonlyArray | undefined => { - const value = options(request)?.include - if (!Array.isArray(value)) return undefined - const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry)) - return filtered.length > 0 ? filtered : undefined -} - -export const promptCacheKey = (request: LLMRequest) => { - const value = options(request)?.promptCacheKey - return typeof value === "string" ? value : undefined -} - -export const textVerbosity = (request: LLMRequest) => { - const value = options(request)?.textVerbosity - return isTextVerbosity(value) ? value : undefined -} - -export const serviceTier = (request: LLMRequest) => { - const value = options(request)?.serviceTier - return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined -} - -export const instructions = (request: LLMRequest) => { - const value = options(request)?.instructions - return typeof value === "string" ? value : undefined -} +export const resolve = OpenResponsesOptions.resolve export * as OpenAIOptions from "./openai-options" diff --git a/packages/ai/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts index 3a311eb34ce..473942939ce 100644 --- a/packages/ai/src/protocols/utils/tool-schema.ts +++ b/packages/ai/src/protocols/utils/tool-schema.ts @@ -63,6 +63,8 @@ const openAI = (schema: JsonSchema): JsonSchema => { return isRecord(normalized) ? normalized : { type: "object" } } +const responses = openAI + const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} const modelCompatibility = ( @@ -83,4 +85,5 @@ export const ToolSchemaProjection = { modelCompatibility, moonshot, openAI, + responses, } as const diff --git a/packages/ai/src/providers/google-vertex-responses.ts b/packages/ai/src/providers/google-vertex-responses.ts index 47ede239964..e36c67472c1 100644 --- a/packages/ai/src/providers/google-vertex-responses.ts +++ b/packages/ai/src/providers/google-vertex-responses.ts @@ -25,6 +25,7 @@ export interface Settings extends ProviderPackage.Settings { const route = OpenAICompatibleResponses.route.with({ id: "google-vertex-responses", provider: id, + providerOptions: { openresponses: { store: false } }, }) export const routes = [route] diff --git a/packages/ai/src/providers/open-responses-options.ts b/packages/ai/src/providers/open-responses-options.ts new file mode 100644 index 00000000000..686cdcb8083 --- /dev/null +++ b/packages/ai/src/providers/open-responses-options.ts @@ -0,0 +1,20 @@ +import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options" +import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" + +export interface OpenResponsesOptionsInput { + readonly [key: string]: unknown + readonly instructions?: string + readonly store?: boolean + readonly promptCacheKey?: string + readonly reasoningEffort?: ReasoningEffort + readonly reasoningSummary?: "auto" | "concise" | "detailed" + readonly include?: ReadonlyArray + readonly textVerbosity?: TextVerbosity + readonly serviceTier?: ServiceTier +} + +export type OpenResponsesProviderOptionsInput = ProviderOptions & { + readonly openresponses?: OpenResponsesOptionsInput +} + +export * as OpenResponsesProviderOptions from "./open-responses-options" diff --git a/packages/ai/src/providers/openai-compatible-responses.ts b/packages/ai/src/providers/openai-compatible-responses.ts index 58b8ab65aef..38293eb1d8d 100644 --- a/packages/ai/src/providers/openai-compatible-responses.ts +++ b/packages/ai/src/providers/openai-compatible-responses.ts @@ -3,7 +3,9 @@ import { OpenAICompatibleResponses } from "../protocols/openai-compatible-respon import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import type { RouteDefaultsInput } from "../route/client" import { ProviderID, type ModelID } from "../schema" -import type { OpenAIProviderOptionsInput } from "./openai-options" +import type { OpenResponsesProviderOptionsInput } from "./open-responses-options" + +export type { OpenResponsesOptionsInput, OpenResponsesProviderOptionsInput } from "./open-responses-options" export const id = ProviderID.make("openai-compatible") @@ -11,13 +13,14 @@ export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly provider?: string readonly baseURL: string + readonly providerOptions?: OpenResponsesProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { readonly apiKey?: string readonly baseURL: string readonly provider?: string - readonly providerOptions?: OpenAIProviderOptionsInput + readonly providerOptions?: OpenResponsesProviderOptionsInput } export const routes = [OpenAICompatibleResponses.route] diff --git a/packages/ai/src/providers/openai-options.ts b/packages/ai/src/providers/openai-options.ts index fb548dd7972..86c7c2e3345 100644 --- a/packages/ai/src/providers/openai-options.ts +++ b/packages/ai/src/providers/openai-options.ts @@ -1,22 +1,10 @@ -import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" +import type { ProviderOptions } from "../schema" import { mergeProviderOptions } from "../schema" -import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" +import type { OpenResponsesOptionsInput } from "./open-responses-options" export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" -export interface OpenAIOptionsInput { - readonly [key: string]: unknown - readonly store?: boolean - readonly promptCacheKey?: string - readonly reasoningEffort?: ReasoningEffort - readonly reasoningSummary?: "auto" - // OpenAI Responses `include` wire field. Mirrors the official SDK's - // `ResponseIncludable[]` union exactly so AI SDK callers and direct - // native-SDK callers share one shape and no translation is required. - readonly include?: ReadonlyArray - readonly textVerbosity?: TextVerbosity - readonly serviceTier?: OpenAIServiceTier -} +export type OpenAIOptionsInput = OpenResponsesOptionsInput export type OpenAIProviderOptionsInput = ProviderOptions & { readonly openai?: OpenAIOptionsInput diff --git a/packages/ai/src/route/protocol.ts b/packages/ai/src/route/protocol.ts index acb1e78c67b..c7340ac063e 100644 --- a/packages/ai/src/route/protocol.ts +++ b/packages/ai/src/route/protocol.ts @@ -12,7 +12,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" * Examples: * * - `OpenAIChat.protocol` — chat completions style - * - `OpenAIResponses.protocol` — responses API + * - `OpenResponses.protocol` — provider-neutral Responses API baseline + * - `OpenAIResponses.protocol` — OpenAI extensions to that baseline * - `AnthropicMessages.protocol` — messages API with content blocks * - `Gemini.protocol` — generateContent * - `BedrockConverse.protocol` — Converse with binary event-stream framing diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index 4629100da51..bea36c4c3be 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -11,7 +11,13 @@ import { XAI, } from "@opencode-ai/ai/providers" import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot" -import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols" +import { + OpenAIChat, + OpenAICompatibleChat, + OpenAICompatibleResponses, + OpenAIResponses, + OpenResponses, +} from "@opencode-ai/ai/protocols" import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" describe("public exports", () => { @@ -74,7 +80,9 @@ describe("public exports", () => { test("protocol barrels expose supported low-level routes", () => { expect(OpenAIChat.route.id).toBe("openai-chat") expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") + expect(OpenResponses.protocol.id).toBe("open-responses") expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses") + expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses") expect(OpenAIResponses.route.id).toBe("openai-responses") expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") expect(AnthropicMessages.route.id).toBe("anthropic-messages") diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 46d4dbb503d..94a86cdfb53 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -59,7 +59,7 @@ describe("provider package entrypoints", () => { headers: { "x-application": "opencode" }, body: { service_tier: "priority" }, limits: { context: 200_000, output: 64_000 }, - providerOptions: { openai: { reasoningEffort: "low", store: true } }, + providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, }) expect(String(selected.provider)).toBe("example") @@ -72,7 +72,7 @@ describe("provider package entrypoints", () => { expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.providerOptions).toEqual({ - openai: { reasoningEffort: "low", store: true }, + openresponses: { reasoningEffort: "low", store: true }, }) }) @@ -235,12 +235,12 @@ describe("provider package entrypoints", () => { path: "/chat/completions", }) expect(responses.route.id).toBe("google-vertex-responses") - expect(responses.route.protocol).toBe("openai-responses") + expect(responses.route.protocol).toBe("open-responses") expect(responses.route.endpoint).toMatchObject({ baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi", path: "/responses", }) - expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } }) + expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } }) }) test("rejects conflicting Vertex auth settings at runtime", async () => { diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index a43acb7683e..39819821097 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -1,17 +1,22 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM } from "../../src" +import { LLM, LLMEvent } from "../../src" import { configure } from "../../src/providers/openai-compatible-responses" +import { OpenAI } from "../../src/providers" +import { OpenResponses } from "../../src/protocols/open-responses" import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" import { OpenAIResponses } from "../../src/protocols/openai-responses" import { LLMClient } from "../../src/route" import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" -describe("OpenAI-compatible Responses route", () => { - it.effect("reuses the OpenAI Responses protocol for a configured deployment", () => +describe("Open Responses-compatible route", () => { + it.effect("uses the Open Responses baseline for a configured deployment", () => Effect.gen(function* () { - expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body) - expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport) + expect(OpenAICompatibleResponses.route.body).toBe(OpenResponses.protocol.body) + expect(OpenAICompatibleResponses.route.transport).toBe(OpenResponses.httpTransport) + expect(OpenAICompatibleResponses.route.body).not.toBe(OpenAIResponses.protocol.body) const model = configure({ apiKey: "test-key", @@ -27,7 +32,7 @@ describe("OpenAI-compatible Responses route", () => { ) expect(prepared.route).toBe("openai-compatible-responses") - expect(prepared.protocol).toBe("openai-responses") + expect(prepared.protocol).toBe("open-responses") expect(prepared.model).toMatchObject({ id: "example-model", provider: "example", @@ -45,9 +50,67 @@ describe("OpenAI-compatible Responses route", () => { { role: "system", content: "You are concise." }, { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, ], - store: false, stream: true, }) }), ) + + it.effect("rejects OpenAI-native tools", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + }).model("example-model") + const error = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }), + ).pipe(Effect.flip) + + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("Open Responses does not support provider-native tool image_generation") + }), + ) + + it.effect("reads standard options from the Open Responses namespace", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, + }).model("example-model") + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." })) + + expect(prepared.body).toMatchObject({ + reasoning: { effort: "low" }, + store: true, + }) + }), + ) + + it.effect("does not interpret OpenAI hosted-tool items", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + provider: "example", + }).model("example-model") + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search." })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.done", + item: { type: "web_search_call", id: "ws_1", status: "completed", action: { query: "news" } }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.toolCalls).toEqual([]) + expect(response.events.find(LLMEvent.is.finish)).toMatchObject({ + providerMetadata: { openresponses: { responseId: "resp_1" } }, + }) + }), + ) })