Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef30690016 | |||
| d811cba574 | |||
| 1e541a8106 | |||
| 39a711cd56 | |||
| 12c5984aa1 | |||
| cbd35e7401 |
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: ideal-pseudocode
|
||||
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
|
||||
---
|
||||
|
||||
# Ideal Pseudocode
|
||||
|
||||
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
|
||||
|
||||
## Loop
|
||||
|
||||
One function per round. Never touch code before the user picks a direction.
|
||||
|
||||
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
|
||||
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
|
||||
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
|
||||
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
|
||||
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
|
||||
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
|
||||
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
|
||||
8. **Offer the next target** and stop.
|
||||
|
||||
## Style
|
||||
|
||||
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
|
||||
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
|
||||
- Prefer early return and `if` chains for control flow; matchers are for producing values.
|
||||
- Compare names against the question each caller is actually asking; rename toward the domain noun.
|
||||
|
||||
## Example round
|
||||
|
||||
Every round should read like this (abridged from a real one, on an Effect step-runner):
|
||||
|
||||
> `runStep` it is. The ideal pseudocode:
|
||||
>
|
||||
> ```ts
|
||||
> // One logical step: one settled model call, however many attempts it takes.
|
||||
> // An attempt can end without settling in two ways:
|
||||
> // transient provider failure -> retry the same call, same assistant message
|
||||
> // compaction rewrote history -> rebuild the request and call again
|
||||
> function runStep(session, promotable, step) {
|
||||
> while (true) {
|
||||
> const result = callModel(session, promotable, step)
|
||||
>
|
||||
> if (result.completed) return result
|
||||
>
|
||||
> if (result.retryable) {
|
||||
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
|
||||
> promotable = none // never re-promote on a second attempt
|
||||
> continue
|
||||
> }
|
||||
>
|
||||
> // compaction restarted the step: fresh request from rewritten history
|
||||
> promotable = none
|
||||
> step = result.step
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Comparing against the real thing, three deltas:
|
||||
>
|
||||
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
|
||||
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
|
||||
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
|
||||
>
|
||||
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
|
||||
>
|
||||
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
|
||||
@@ -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`, `OpenResponses.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`, `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<object>` value alongside its protocol.
|
||||
@@ -158,14 +158,13 @@ packages/ai/src/
|
||||
protocols/
|
||||
shared.ts ProviderShared toolkit used inside protocol impls
|
||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||
open-responses.ts provider-neutral Responses protocol baseline
|
||||
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
|
||||
openai-responses.ts
|
||||
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 deployment adapter that reuses OpenResponses.protocol, no canonical URL
|
||||
openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL
|
||||
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
|
||||
providers/
|
||||
openai-compatible.ts generic Chat helper + family model helpers
|
||||
@@ -176,7 +175,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. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
|
||||
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.
|
||||
|
||||
### Shared protocol helpers
|
||||
|
||||
@@ -241,7 +240,7 @@ const get_weather = tool({
|
||||
|
||||
const tools = { get_weather, get_time, ... }
|
||||
const events = yield* LLM.stream(
|
||||
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
|
||||
LLM.updateRequest(request, { tools: Tool.toDefinitions(tools) }),
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
|
||||
@@ -315,8 +315,7 @@ const longer = {
|
||||
}
|
||||
```
|
||||
|
||||
There is no `LLM.updateRequest(...)` helper. The current Schema-backed implementation
|
||||
uses `LLMRequest.update(...)` when canonical request data must be derived.
|
||||
There is no `LLM.updateRequest(...)` helper and no request Schema class.
|
||||
|
||||
### Conversation history
|
||||
|
||||
@@ -437,7 +436,7 @@ const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
|
||||
if (call && !call.providerExecuted) {
|
||||
const dispatched = yield * ToolRuntime.dispatch(tools, call)
|
||||
const followUp = LLMRequest.update(request, {
|
||||
const followUp = LLM.updateRequest(request, {
|
||||
messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })],
|
||||
})
|
||||
// Caller must invoke the provider again and repeat the loop.
|
||||
|
||||
@@ -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`; 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.
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
+24
-24
@@ -1,6 +1,6 @@
|
||||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-24
|
||||
Last reviewed: 2026-07-17
|
||||
|
||||
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/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. |
|
||||
| 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. |
|
||||
|
||||
## 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. 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.
|
||||
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.
|
||||
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`. |
|
||||
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||
| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible 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. |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
@@ -78,10 +78,7 @@ const streamText = LLM.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||
if (event.type === "finish")
|
||||
process.stdout.write(
|
||||
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
|
||||
)
|
||||
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
|
||||
}),
|
||||
),
|
||||
Stream.runDrain,
|
||||
@@ -116,7 +113,7 @@ const streamWithTools = Effect.gen(function* () {
|
||||
|
||||
// A durable agent would persist these messages before starting another
|
||||
// raw model turn. This tutorial keeps the boundary visible instead.
|
||||
const followUp = LLMRequest.update(request, {
|
||||
const followUp = LLM.updateRequest(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
Message.assistant([event]),
|
||||
@@ -197,7 +194,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
event: Schema.String,
|
||||
initial: () => undefined,
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
|
||||
onHalt: () => [{ type: "finish", reason: "stop" }],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+20
-2
@@ -9,13 +9,24 @@ import {
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
type ModelInput as SchemaModelInput,
|
||||
SystemPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
type ContentPart,
|
||||
ToolResultPart,
|
||||
} from "./schema"
|
||||
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
|
||||
|
||||
export type ModelInput = SchemaModelInput
|
||||
|
||||
export type MessageInput = Message.Input
|
||||
|
||||
export type ToolChoiceInput = ToolChoice.Input
|
||||
export type ToolChoiceMode = ToolChoice.Mode
|
||||
|
||||
export type ToolResultInput = Parameters<typeof ToolResultPart.make>[0]
|
||||
|
||||
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
|
||||
export type RequestInput = Omit<
|
||||
ConstructorParameters<typeof LLMRequest>[0],
|
||||
@@ -23,9 +34,9 @@ export type RequestInput = Omit<
|
||||
> & {
|
||||
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
|
||||
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
|
||||
readonly messages?: ReadonlyArray<Message | Message.Input>
|
||||
readonly messages?: ReadonlyArray<Message | MessageInput>
|
||||
readonly tools?: ReadonlyArray<ToolDefinition.Input>
|
||||
readonly toolChoice?: ToolChoice.Input
|
||||
readonly toolChoice?: ToolChoiceInput
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
|
||||
readonly http?: HttpOptions.Input
|
||||
@@ -35,6 +46,10 @@ export const generate = LLMClient.generate
|
||||
|
||||
export const stream = LLMClient.stream
|
||||
|
||||
export const requestInput = (input: LLMRequest): RequestInput => ({
|
||||
...LLMRequest.input(input),
|
||||
})
|
||||
|
||||
export const request = (input: RequestInput) => {
|
||||
const {
|
||||
system: requestSystem,
|
||||
@@ -59,6 +74,9 @@ export const request = (input: RequestInput) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const updateRequest = (input: LLMRequest, patch: Partial<RequestInput>) =>
|
||||
request({ ...requestInput(input), ...patch })
|
||||
|
||||
const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
||||
|
||||
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderOptions,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
@@ -32,29 +31,6 @@ const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderS
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({ readonly type: "enabled" } & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = ProviderOptions & {
|
||||
readonly anthropic?: OptionsInput
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
@@ -99,15 +75,6 @@ const AnthropicThinkingBlock = Schema.Struct({
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
// Safety-filtered thinking arrives as an opaque encrypted `data` payload with
|
||||
// no visible text. It must round-trip verbatim so multi-turn thinking + tool
|
||||
// use conversations keep their reasoning continuity.
|
||||
const AnthropicRedactedThinkingBlock = Schema.Struct({
|
||||
type: Schema.tag("redacted_thinking"),
|
||||
data: Schema.String,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
const AnthropicToolUseBlock = Schema.Struct({
|
||||
type: Schema.tag("tool_use"),
|
||||
id: Schema.String,
|
||||
@@ -169,7 +136,6 @@ type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicTextBlock,
|
||||
AnthropicThinkingBlock,
|
||||
AnthropicRedactedThinkingBlock,
|
||||
AnthropicToolUseBlock,
|
||||
AnthropicServerToolUseBlock,
|
||||
AnthropicServerToolResultBlock,
|
||||
@@ -248,9 +214,6 @@ const AnthropicStreamBlock = Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
@@ -324,12 +287,6 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -515,16 +472,11 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
|
||||
// thinking; only signature-less parts carrying redactedData
|
||||
// round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
}
|
||||
content.push({ type: "thinking", thinking: part.text, signature })
|
||||
content.push({
|
||||
type: "thinking",
|
||||
thinking: part.text,
|
||||
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -561,38 +513,37 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions?.anthropic
|
||||
return {
|
||||
thinking: yield* resolveThinking(input?.thinking),
|
||||
effort: typeof input?.effort === "string" ? input.effort : undefined,
|
||||
}
|
||||
})
|
||||
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "adaptive") {
|
||||
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
||||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking)) return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
const display =
|
||||
input.display === "summarized"
|
||||
thinking.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: input.display === "omitted"
|
||||
: thinking.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
if (thinking.type === "disabled") return { type: "disabled" as const }
|
||||
if (thinking.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
: typeof thinking.budget_tokens === "number"
|
||||
? thinking.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
})
|
||||
|
||||
const outputConfig = (request: LLMRequest) => {
|
||||
const effort = anthropicOptions(request)?.effort
|
||||
return typeof effort === "string" ? { effort } : undefined
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
@@ -612,7 +563,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
),
|
||||
)
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const toolChoice =
|
||||
tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
@@ -627,7 +579,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
const options = yield* resolveOptions(request)
|
||||
return {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -640,8 +591,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
output_config: options.effort === undefined ? undefined : { effort: options.effort },
|
||||
thinking: yield* lowerThinking(request),
|
||||
output_config: outputConfig(request),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -650,7 +601,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "max_tokens") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "refusal") return "content-filter"
|
||||
return "unknown"
|
||||
@@ -796,25 +747,6 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
]
|
||||
}
|
||||
|
||||
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||
// payload as `redactedData` metadata (same model as Vercel's
|
||||
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||
if (block.type === "redacted_thinking" && block.data) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ redactedData: block.data }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block)
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
@@ -904,10 +836,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type ModelToolSchemaCompatibility,
|
||||
@@ -66,15 +65,14 @@ const BedrockToolResultBlock = Schema.Struct({
|
||||
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
|
||||
|
||||
const BedrockReasoningBlock = Schema.Struct({
|
||||
reasoningContent: Schema.Union([
|
||||
Schema.Struct({
|
||||
reasoningText: Schema.Struct({
|
||||
reasoningContent: Schema.Struct({
|
||||
reasoningText: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.String,
|
||||
signature: Schema.optional(Schema.String),
|
||||
}),
|
||||
}),
|
||||
Schema.Struct({ redactedContent: Schema.String }),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const BedrockUserBlock = Schema.Union([
|
||||
@@ -155,12 +153,6 @@ const BedrockUsageSchema = Schema.Struct({
|
||||
})
|
||||
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
|
||||
|
||||
const BedrockStreamException = Schema.Struct({
|
||||
message: Schema.optional(Schema.String),
|
||||
originalMessage: Schema.optional(Schema.String),
|
||||
originalStatusCode: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
// Streaming event shape — the AWS event stream wraps each JSON payload by its
|
||||
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
|
||||
// reconstruct that wrapping in `decodeFrames` below so the event schema can
|
||||
@@ -188,11 +180,6 @@ const BedrockEvent = Schema.Struct({
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// Blob fields in Bedrock's JSON event stream are base64 strings.
|
||||
redactedContent: Schema.optional(Schema.String),
|
||||
// Vercel's Bedrock provider exposes the same delta under
|
||||
// Anthropic's shorter `data` spelling.
|
||||
data: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -212,11 +199,11 @@ const BedrockEvent = Schema.Struct({
|
||||
metrics: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
internalServerException: Schema.optional(BedrockStreamException),
|
||||
modelStreamErrorException: Schema.optional(BedrockStreamException),
|
||||
validationException: Schema.optional(BedrockStreamException),
|
||||
throttlingException: Schema.optional(BedrockStreamException),
|
||||
serviceUnavailableException: Schema.optional(BedrockStreamException),
|
||||
internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
||||
modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
||||
validationException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
||||
throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
||||
serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
||||
})
|
||||
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
|
||||
@@ -272,13 +259,6 @@ const reasoningSignature = (part: ReasoningPart) => {
|
||||
)
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string"
|
||||
? bedrock.redactedData
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
@@ -368,13 +348,11 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const signature = reasoningSignature(part)
|
||||
const redactedData = reasoningRedactedData(part)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ reasoningContent: { redactedContent: redactedData } })
|
||||
continue
|
||||
}
|
||||
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
|
||||
content.push({
|
||||
reasoningContent: {
|
||||
reasoningText: { text: part.text, signature: reasoningSignature(part) },
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -457,10 +435,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string): FinishReason => {
|
||||
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "max_tokens") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -489,7 +466,7 @@ interface ParserState {
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
||||
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -540,26 +517,12 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||
const providerMetadata = reasoning.signature
|
||||
? bedrockMetadata({ signature: reasoning.signature })
|
||||
: redactedData !== undefined
|
||||
? bedrockMetadata({ redactedData })
|
||||
: undefined
|
||||
const lifecycle =
|
||||
reasoning.text !== undefined || providerMetadata !== undefined
|
||||
? Lifecycle.reasoningDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
reasoning.text ?? "",
|
||||
providerMetadata,
|
||||
)
|
||||
: state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
lifecycle: reasoning.text
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
|
||||
: state.lifecycle,
|
||||
reasoningSignatures: reasoning.signature
|
||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
: state.reasoningSignatures,
|
||||
@@ -620,30 +583,15 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
},
|
||||
usage: state.pendingFinish?.usage,
|
||||
},
|
||||
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: {
|
||||
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
||||
usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
const usage = mapUsage(event.metadata.usage)
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
}
|
||||
|
||||
const exception = (
|
||||
@@ -660,7 +608,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
|
||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
}),
|
||||
})
|
||||
@@ -676,13 +624,8 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized:
|
||||
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
||||
? "tool-calls"
|
||||
: state.pendingFinish.reason.normalized,
|
||||
},
|
||||
reason:
|
||||
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
|
||||
@@ -53,22 +53,8 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
})
|
||||
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
|
||||
|
||||
const messageType = decoded.headers[":message-type"]?.value
|
||||
if (messageType === "error") {
|
||||
const code = decoded.headers[":error-code"]?.value
|
||||
const message = decoded.headers[":error-message"]?.value
|
||||
return yield* ProviderShared.eventError(
|
||||
route,
|
||||
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
|
||||
"Bedrock Converse event-stream error",
|
||||
)
|
||||
}
|
||||
const eventType =
|
||||
messageType === "event"
|
||||
? decoded.headers[":event-type"]?.value
|
||||
: messageType === "exception"
|
||||
? decoded.headers[":exception-type"]?.value
|
||||
: undefined
|
||||
if (decoded.headers[":message-type"]?.value !== "event") continue
|
||||
const eventType = decoded.headers[":event-type"]?.value
|
||||
if (typeof eventType !== "string") continue
|
||||
const payload = utf8.decode(decoded.body)
|
||||
if (!payload) continue
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderOptions,
|
||||
type ProviderMetadata,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
@@ -27,18 +26,6 @@ const ADAPTER = "gemini"
|
||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = ProviderOptions & {
|
||||
readonly gemini?: OptionsInput
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
@@ -216,9 +203,7 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||
? google.functionCallId
|
||||
: undefined
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
@@ -315,22 +300,21 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const value = request.providerOptions?.gemini?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return {}
|
||||
const thinkingConfig = {
|
||||
const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
|
||||
|
||||
const thinkingConfig = (request: LLMRequest) => {
|
||||
const value = geminiOptions(request)?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return undefined
|
||||
const result = {
|
||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
||||
}
|
||||
return {
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
return Object.values(result).some((item) => item !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const hasTools = request.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = resolveOptions(request)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -338,7 +322,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
topP: generation?.topP,
|
||||
topK: generation?.topK,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
thinkingConfig: thinkingConfig(request),
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -398,22 +382,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
finishReason === "SAFETY" ||
|
||||
finishReason === "BLOCKLIST" ||
|
||||
finishReason === "PROHIBITED_CONTENT" ||
|
||||
finishReason === "SPII" ||
|
||||
finishReason === "MODEL_ARMOR" ||
|
||||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
|
||||
finishReason === "IMAGE_RECITATION" ||
|
||||
finishReason === "LANGUAGE"
|
||||
finishReason === "SPII"
|
||||
)
|
||||
return "content-filter"
|
||||
if (
|
||||
finishReason === "MALFORMED_FUNCTION_CALL" ||
|
||||
finishReason === "UNEXPECTED_TOOL_CALL" ||
|
||||
finishReason === "NO_IMAGE" ||
|
||||
finishReason === "TOO_MANY_TOOL_CALLS" ||
|
||||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
|
||||
finishReason === "MALFORMED_RESPONSE"
|
||||
)
|
||||
return "error"
|
||||
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -430,10 +402,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
raw: state.finishReason,
|
||||
},
|
||||
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
|
||||
@@ -6,4 +6,3 @@ 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"
|
||||
|
||||
@@ -1,949 +0,0 @@
|
||||
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<string>([...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<typeof MediaInput>
|
||||
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<typeof OpenResponsesInputItem>
|
||||
|
||||
// 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<OpenResponsesReasoningInput, "id">
|
||||
|
||||
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<typeof OpenResponsesBody>
|
||||
|
||||
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<typeof OpenResponsesUsage>
|
||||
|
||||
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<typeof StreamItem>
|
||||
|
||||
// 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<typeof Event>
|
||||
|
||||
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<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
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<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<LLMRequest["toolChoice"]>) =>
|
||||
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<ToolContent> = 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<string, OpenResponsesReasoningReplay> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
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<ToolContent> = 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<string, unknown>): 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<LLMEvent>]
|
||||
|
||||
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<StepResult>([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<string>(),
|
||||
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<OpenResponsesBody>()
|
||||
|
||||
export * as OpenResponses from "./open-responses"
|
||||
@@ -5,11 +5,9 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
@@ -167,18 +164,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const OpenAIChatEvent = Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
choices: Schema.Array(OpenAIChatChoice),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
})
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
@@ -194,7 +184,7 @@ export interface ParserState {
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly finishReason?: FinishReason
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: string
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
@@ -396,13 +386,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
||||
return {
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
@@ -433,7 +424,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stop: generation?.stop,
|
||||
...lowerOptions(request),
|
||||
...(yield* lowerOptions(request)),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -448,7 +439,6 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "length") return "length"
|
||||
if (reason === "content_filter") return "content-filter"
|
||||
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
||||
if (reason === "error") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -542,22 +532,10 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
|
||||
|
||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.error)
|
||||
return yield* new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices?.[0]
|
||||
const finishReason = choice?.finish_reason
|
||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
||||
: state.finishReason
|
||||
const choice = event.choices[0]
|
||||
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
@@ -649,13 +627,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const events: LLMEvent[] = []
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason
|
||||
? {
|
||||
...state.finishReason,
|
||||
normalized:
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: undefined
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
const metadata = reasoningMetadata(
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { OpenResponses } from "./open-responses"
|
||||
import { OpenAIResponses } from "./openai-responses"
|
||||
|
||||
const ADAPTER = "openai-compatible-responses"
|
||||
|
||||
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
providerMetadataKey: "openresponses",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
providerMetadataKey: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenAIResponses.PATH),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
|
||||
@@ -2,20 +2,134 @@ import { Effect, Encoding, Schema } from "effect"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Protocol } from "../route/protocol"
|
||||
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 { 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 { Lifecycle } from "./utils/lifecycle"
|
||||
import { OpenAIImage } from "./utils/openai-image"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
import { OpenAIImage } from "./utils/openai-image"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = OpenResponses.PATH
|
||||
export const PATH = "/responses"
|
||||
|
||||
// =============================================================================
|
||||
// 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<typeof OpenAIResponsesInputContent>
|
||||
|
||||
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<typeof OpenAIResponsesInputItem>
|
||||
|
||||
// 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<OpenAIResponsesReasoningInput, "id">
|
||||
|
||||
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"])),
|
||||
@@ -27,18 +141,43 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
|
||||
size: Schema.optional(OpenAIImage.Size),
|
||||
})
|
||||
|
||||
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
|
||||
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
|
||||
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
|
||||
|
||||
const OpenAIResponsesToolChoice = Schema.Union([
|
||||
OpenResponses.ToolChoice,
|
||||
Schema.Literals(["auto", "none", "required"]),
|
||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||
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 = {
|
||||
...OpenResponses.coreFields,
|
||||
model: Schema.String,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
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({
|
||||
@@ -57,20 +196,100 @@ const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
|
||||
type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
|
||||
const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
|
||||
|
||||
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 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<typeof OpenAIResponsesUsage>
|
||||
|
||||
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<typeof OpenAIResponsesStreamItem>
|
||||
|
||||
// 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<typeof OpenAIResponsesEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
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<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
||||
}
|
||||
|
||||
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
|
||||
@@ -85,13 +304,20 @@ 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* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid")
|
||||
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* OpenResponses.lowerTool(NAME, tool, inputSchema)
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
|
||||
ProviderShared.matchToolChoice(NAME, toolChoice, {
|
||||
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "required" as const,
|
||||
@@ -101,14 +327,241 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
: { type: "function" as const, name },
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequest(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
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<ToolContent> = 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<string, OpenAIResponsesReasoningReplay> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
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<ToolContent> = 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 toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
...body,
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -116,25 +569,58 @@ 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,
|
||||
} satisfies OpenAIResponsesBody
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
...options,
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
// 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 },
|
||||
})
|
||||
}
|
||||
|
||||
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<string, unknown>): 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 ?? {} },
|
||||
@@ -150,28 +636,38 @@ 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: HostedToolData) => unknown }>
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{ readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown }
|
||||
>
|
||||
|
||||
type HostedToolType = keyof typeof HOSTED_TOOLS
|
||||
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
|
||||
|
||||
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
|
||||
const isHostedToolItem = (
|
||||
item: OpenAIResponsesStreamItem,
|
||||
): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } =>
|
||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
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
|
||||
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/${format};base64,${item.result}`,
|
||||
mime: `image/${format}`,
|
||||
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
|
||||
mime: `image/${item.output_format ?? "png"}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -179,15 +675,12 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
})
|
||||
|
||||
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
|
||||
state: OpenResponses.ParserState,
|
||||
item: HostedToolItem,
|
||||
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
|
||||
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
|
||||
) {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
return [
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
@@ -202,20 +695,363 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
]
|
||||
})
|
||||
|
||||
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)
|
||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
|
||||
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 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: mapFinishReason(event, state.hasFunctionCall),
|
||||
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<StepResult>([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: {
|
||||
@@ -223,10 +1059,16 @@ export const protocol = Protocol.make({
|
||||
from: fromRequest,
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
event: Protocol.jsonEvent(OpenAIResponsesEvent),
|
||||
initial: (request) => ({
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningItems: {},
|
||||
store: OpenAIOptions.store(request),
|
||||
}),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
||||
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
||||
|
||||
export interface State {
|
||||
readonly stepStarted: boolean
|
||||
@@ -81,7 +81,7 @@ export const finish = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
input: {
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly reason: FinishReason
|
||||
readonly usage?: Usage
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(ResponseIncludables)
|
||||
const SERVICE_TIERS = new Set<string>(ServiceTiers)
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
|
||||
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<ResponseIncludable>
|
||||
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
|
||||
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"
|
||||
@@ -1,23 +1,85 @@
|
||||
import { ReasoningEfforts } from "../../schema"
|
||||
import { OpenResponsesOptions } from "./open-responses-options"
|
||||
import { Schema } from "effect"
|
||||
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
||||
|
||||
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 = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
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 OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
|
||||
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
|
||||
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
|
||||
|
||||
export const OpenAIReasoningEffort = Schema.String
|
||||
export const OpenAITextVerbosity = TextVerbosity
|
||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
|
||||
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
|
||||
|
||||
export const resolve = OpenResponsesOptions.resolve
|
||||
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<OpenAIResponseIncludable> | 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 * as OpenAIOptions from "./openai-options"
|
||||
|
||||
@@ -63,8 +63,6 @@ const openAI = (schema: JsonSchema): JsonSchema => {
|
||||
return isRecord(normalized) ? normalized : { type: "object" }
|
||||
}
|
||||
|
||||
const responses = openAI
|
||||
|
||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||
|
||||
const modelCompatibility = (
|
||||
@@ -85,5 +83,4 @@ export const ToolSchemaProjection = {
|
||||
modelCompatibility,
|
||||
moonshot,
|
||||
openAI,
|
||||
responses,
|
||||
} as const
|
||||
|
||||
@@ -5,17 +5,12 @@ import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
export const id = ProviderID.make("anthropic-compatible")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
@@ -25,7 +20,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
) & {
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
@@ -67,7 +61,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,11 @@ import { ProviderID, type ModelID } from "../schema"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
export const id = ProviderID.make("anthropic")
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
@@ -26,7 +18,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
@@ -61,6 +52,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,9 @@ import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
|
||||
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
||||
@@ -23,7 +19,6 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
@@ -32,7 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
|
||||
@@ -25,7 +25,6 @@ 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]
|
||||
|
||||
@@ -4,12 +4,9 @@ import { Auth } from "../route/auth"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
@@ -17,7 +14,6 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
@@ -28,7 +24,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { RouteDefaultsInput } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema"
|
||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { Gemini } from "../protocols/gemini"
|
||||
import { GoogleImages } from "../protocols/google-images"
|
||||
|
||||
export type { GoogleImageOptions } from "../protocols/google-images"
|
||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||
|
||||
export const id = ProviderID.make("google")
|
||||
|
||||
@@ -17,13 +15,12 @@ export const routes = [Gemini.route]
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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<ResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
readonly serviceTier?: ServiceTier
|
||||
}
|
||||
|
||||
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
|
||||
readonly openresponses?: OpenResponsesOptionsInput
|
||||
}
|
||||
|
||||
export * as OpenResponsesProviderOptions from "./open-responses-options"
|
||||
@@ -3,9 +3,7 @@ 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 { OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||
|
||||
export type { OpenResponsesOptionsInput, OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
|
||||
@@ -13,14 +11,13 @@ 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?: OpenResponsesProviderOptionsInput
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleResponses.route]
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { ProviderOptions } from "../schema"
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||
import { mergeProviderOptions } from "../schema"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options"
|
||||
import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
||||
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
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<OpenAIResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
}
|
||||
|
||||
export type OpenAIProviderOptionsInput = ProviderOptions & {
|
||||
readonly openai?: OpenAIOptionsInput
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
||||
* Examples:
|
||||
*
|
||||
* - `OpenAIChat.protocol` — chat completions style
|
||||
* - `OpenResponses.protocol` — provider-neutral Responses API baseline
|
||||
* - `OpenAIResponses.protocol` — OpenAI extensions to that baseline
|
||||
* - `OpenAIResponses.protocol` — responses API
|
||||
* - `AnthropicMessages.protocol` — messages API with content blocks
|
||||
* - `Gemini.protocol` — generateContent
|
||||
* - `BedrockConverse.protocol` — Converse with binary event-stream framing
|
||||
|
||||
@@ -191,16 +191,10 @@ export const ToolError = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.ToolError" })
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
export const FinishReasonDetails = Schema.Struct({
|
||||
normalized: FinishReason,
|
||||
raw: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
||||
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
||||
|
||||
export const StepFinish = Schema.Struct({
|
||||
type: Schema.tag("step-finish"),
|
||||
index: Schema.Number,
|
||||
reason: FinishReasonDetails,
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
||||
@@ -208,7 +202,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const Finish = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
reason: FinishReasonDetails,
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
@@ -371,7 +365,7 @@ interface ResponseState {
|
||||
readonly events: ReadonlyArray<LLMEvent>
|
||||
readonly message: Message
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly finishReason?: FinishReason
|
||||
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
||||
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
||||
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
||||
@@ -399,7 +393,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
finishReason: state.finishReason ?? { normalized: "error" },
|
||||
finishReason: state.finishReason ?? "error",
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -586,7 +580,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
||||
message: Message,
|
||||
events: Schema.Array(LLMEvent),
|
||||
usage: Schema.optional(Usage),
|
||||
finishReason: FinishReasonDetails,
|
||||
finishReason: FinishReason,
|
||||
}) {
|
||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||
get text() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMRequest, LLMResponse } from "../src"
|
||||
import { LLM, LLMResponse } from "../src"
|
||||
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
|
||||
import { Model } from "../src/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish"
|
||||
? { type: "finish", reason: { normalized: event.reason } }
|
||||
? { type: "finish", reason: event.reason }
|
||||
: { type: "text-delta", id: "text-0", text: event.text }
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
@@ -141,7 +141,7 @@ describe("llm route", () => {
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const prepared = yield* llm.prepare(
|
||||
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
|
||||
LLM.updateRequest(request, { model: updateModel(request.model, { route: configuredGemini }) }),
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("gemini-fake")
|
||||
@@ -174,7 +174,7 @@ describe("llm route", () => {
|
||||
})
|
||||
|
||||
const prepared = yield* (yield* LLMClient.Service).prepare(
|
||||
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
|
||||
LLM.updateRequest(request, { model: updateModel(request.model, { route: duplicate }) }),
|
||||
)
|
||||
|
||||
expect(prepared.body).toEqual({ body: "late-default" })
|
||||
|
||||
@@ -137,26 +137,15 @@ Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deploym
|
||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||
|
||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
||||
Anthropic.configure({
|
||||
apiKey: "anthropic-key",
|
||||
providerOptions: {
|
||||
anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 }, effort: "high" },
|
||||
},
|
||||
}).model("claude-haiku")
|
||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
||||
// @ts-expect-error Anthropic package settings accept only one auth source.
|
||||
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
|
||||
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
|
||||
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled" } } } })
|
||||
// @ts-expect-error Anthropic thinking budgets must be numbers.
|
||||
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: "large" } } } })
|
||||
|
||||
AnthropicCompatible.configure({
|
||||
apiKey: "messages-key",
|
||||
baseURL: "https://messages.example.com/v1",
|
||||
provider: "example",
|
||||
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
|
||||
}).model("compatible-model")
|
||||
// @ts-expect-error Anthropic-compatible providers require a base URL.
|
||||
AnthropicCompatible.configure({ apiKey: "messages-key" })
|
||||
@@ -170,19 +159,10 @@ AnthropicCompatible.model("compatible-model", {
|
||||
})
|
||||
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
||||
Google.configure({
|
||||
apiKey: "google-key",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
||||
}).model("gemini-2.5-flash")
|
||||
// @ts-expect-error Google model selectors only accept model ids.
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
|
||||
// @ts-expect-error Gemini thinking budgets must be numbers.
|
||||
Google.configure({ providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } } })
|
||||
|
||||
GoogleVertex.configure({
|
||||
apiKey: "vertex-key",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
|
||||
}).model("gemini-3.5-flash")
|
||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
||||
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
||||
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
||||
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
||||
@@ -228,11 +208,7 @@ GoogleVertexResponses.configure({
|
||||
project: "project",
|
||||
})
|
||||
|
||||
GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "project",
|
||||
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" } },
|
||||
}).model("claude-sonnet-4-6")
|
||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
XAI,
|
||||
} from "@opencode-ai/ai/providers"
|
||||
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
|
||||
import {
|
||||
OpenAIChat,
|
||||
OpenAICompatibleChat,
|
||||
OpenAICompatibleResponses,
|
||||
OpenAIResponses,
|
||||
OpenResponses,
|
||||
} from "@opencode-ai/ai/protocols"
|
||||
import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
|
||||
describe("public exports", () => {
|
||||
@@ -80,9 +74,7 @@ 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")
|
||||
|
||||
@@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const assistantContent: ContentPart[] = []
|
||||
const toolCalls: ToolCallPart[] = []
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
|
||||
let usage: Usage | undefined
|
||||
let providerMetadata: ProviderMetadata | undefined
|
||||
|
||||
|
||||
@@ -2,16 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { CacheHint, LLM, LLMResponse } from "../src"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||
import {
|
||||
GenerationOptions,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
} from "../src/schema"
|
||||
import { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
|
||||
|
||||
const chatRoute = OpenAIChat.route
|
||||
const responsesRoute = OpenAIResponses.route
|
||||
@@ -40,8 +31,8 @@ describe("llm constructors", () => {
|
||||
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
const updated = LLMRequest.update(base, {
|
||||
generation: GenerationOptions.make({ maxTokens: 20 }),
|
||||
const updated = LLM.updateRequest(base, {
|
||||
generation: { maxTokens: 20 },
|
||||
messages: [...base.messages, Message.assistant("Hi.")],
|
||||
})
|
||||
|
||||
@@ -200,7 +191,7 @@ describe("llm constructors", () => {
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "finish", reason: { normalized: "stop" } },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
}),
|
||||
).toBe("hi")
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("provider package entrypoints", () => {
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
||||
providerOptions: { openai: { 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({
|
||||
openresponses: { reasoningEffort: "low", store: true },
|
||||
openai: { reasoningEffort: "low", store: true },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,7 +85,6 @@ describe("provider package entrypoints", () => {
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { anthropic: { effort: "low" } },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
@@ -97,19 +96,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ anthropic: { effort: "low" } })
|
||||
})
|
||||
|
||||
test("maps Anthropic provider options onto the executable model", async () => {
|
||||
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
||||
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { anthropic: { thinking: { type: "adaptive" } } },
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
anthropic: { thinking: { type: "adaptive" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
||||
@@ -249,12 +235,12 @@ describe("provider package entrypoints", () => {
|
||||
path: "/chat/completions",
|
||||
})
|
||||
expect(responses.route.id).toBe("google-vertex-responses")
|
||||
expect(responses.route.protocol).toBe("open-responses")
|
||||
expect(responses.route.protocol).toBe("openai-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({ openresponses: { store: false } })
|
||||
expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
|
||||
})
|
||||
|
||||
test("rejects conflicting Vertex auth settings at runtime", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
|
||||
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
||||
@@ -60,7 +60,7 @@ describe("Anthropic Messages route", () => {
|
||||
it.effect("lowers adaptive thinking settings with effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
providerOptions: {
|
||||
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
},
|
||||
@@ -74,42 +74,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes enabled and disabled thinking settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
|
||||
}),
|
||||
)
|
||||
const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
|
||||
}),
|
||||
)
|
||||
const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(enabled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1_024 })
|
||||
expect(legacy.body.thinking).toEqual({ type: "enabled", budget_tokens: 2_048 })
|
||||
expect(disabled.body.thinking).toEqual({ type: "disabled" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects enabled thinking without a budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
@@ -445,34 +409,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
|
||||
{ type: "reasoning", text: "visible", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: "opaque_1" },
|
||||
{ type: "thinking", thinking: "visible", signature: "sig_1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -512,149 +448,12 @@ describe("Anthropic Messages route", () => {
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
reason: "stop",
|
||||
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "redacted_thinking", data: "opaque_1" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-start")).toMatchObject({
|
||||
providerMetadata: { anthropic: { redactedData: "opaque_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
|
||||
{ type: "text", text: "Hello" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips streamed redacted thinking with tool use into a continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
// Anthropic types `redacted_thinking.data` as an opaque string. Its
|
||||
// contents are provider-owned and must be replayed without inspection.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "redacted_thinking", data: redactedData },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Say hello."),
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
],
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Say hello." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: redactedData },
|
||||
{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: "sunny",
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps context-window truncation to length", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "model_context_window_exceeded" },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves pause_turn while normalizing it to stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -666,8 +465,8 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
@@ -704,16 +503,10 @@ describe("Anthropic Messages route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
reason: "tool-calls",
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
@@ -851,10 +644,8 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
|
||||
],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -883,10 +674,7 @@ describe("Anthropic Messages route", () => {
|
||||
},
|
||||
})
|
||||
expect(response.text).toBe("Found it.")
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -914,10 +702,8 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
|
||||
],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1033,10 +819,7 @@ describe("Anthropic Messages route", () => {
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||
{
|
||||
type: "document",
|
||||
source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" },
|
||||
},
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,16 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
LLM,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
} from "../../src"
|
||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
@@ -43,26 +34,6 @@ const eventFrame = (type: string, payload: object) =>
|
||||
body: utf8Encoder.encode(JSON.stringify(payload)),
|
||||
})
|
||||
|
||||
const exceptionFrame = (type: string, payload: object) =>
|
||||
codec.encode({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "exception" },
|
||||
":exception-type": { type: "string", value: type },
|
||||
":content-type": { type: "string", value: "application/json" },
|
||||
},
|
||||
body: utf8Encoder.encode(JSON.stringify(payload)),
|
||||
})
|
||||
|
||||
const errorFrame = (code: string, message: string) =>
|
||||
codec.encode({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "error" },
|
||||
":error-code": { type: "string", value: code },
|
||||
":error-message": { type: "string", value: message },
|
||||
},
|
||||
body: new Uint8Array(),
|
||||
})
|
||||
|
||||
const concat = (frames: ReadonlyArray<Uint8Array>) => {
|
||||
const total = frames.reduce((sum, frame) => sum + frame.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
@@ -115,9 +86,7 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLMRequest.update(baseRequest, {
|
||||
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
|
||||
}),
|
||||
LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
|
||||
)
|
||||
|
||||
// Converse's inferenceConfig has no topK; Anthropic/Nova read it from
|
||||
@@ -154,13 +123,13 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("prepares tool config with toolSpec and toolChoice", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMRequest.update(baseRequest, {
|
||||
LLM.updateRequest(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
||||
}),
|
||||
},
|
||||
],
|
||||
toolChoice: ToolChoice.make({ type: "required" }),
|
||||
}),
|
||||
@@ -188,13 +157,13 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMRequest.update(baseRequest, {
|
||||
LLM.updateRequest(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
||||
}),
|
||||
},
|
||||
],
|
||||
toolChoice: ToolChoice.make({ type: "none" }),
|
||||
}),
|
||||
@@ -321,10 +290,7 @@ describe("Bedrock Converse route", () => {
|
||||
// `metadata` (carries usage). We consolidate them into a single
|
||||
// terminal `finish` event with both.
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
})
|
||||
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
@@ -333,23 +299,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["model_context_window_exceeded", "length"],
|
||||
["malformed_model_output", "error"],
|
||||
["malformed_tool_use", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -383,19 +332,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves usage across later metadata events without usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
|
||||
["metadata", { metrics: { latencyMs: 100 } }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -413,8 +349,8 @@ describe("Bedrock Converse route", () => {
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(baseRequest, {
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
@@ -426,10 +362,7 @@ describe("Bedrock Converse route", () => {
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -455,7 +388,7 @@ describe("Bedrock Converse route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -511,170 +444,12 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
|
||||
],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
||||
],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Let me think.",
|
||||
providerMetadata: { bedrock: { signature: "sig_1" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({ model, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves signature-only reasoning blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { bedrock: { signature: "sig_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts Vercel-compatible redacted reasoning data deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { data: redactedData } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips streamed redacted reasoning with tool use into a continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
// Bedrock represents redactedContent blobs as base64 strings on its JSON
|
||||
// wire. The provider owns the payload and requires byte-exact replay.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
contentBlockIndex: 1,
|
||||
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||
},
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 1, delta: { toolUse: { input: '{"query":"weather"}' } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 1 }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
})
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Say hello."),
|
||||
response.message,
|
||||
Message.tool({ id: "tool_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Say hello." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ reasoningContent: { redactedContent: redactedData } },
|
||||
{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ toolResult: { toolUseId: "tool_1", content: [{ text: "sunny" }], status: "success" } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies throttlingException as a rate limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = concat([
|
||||
eventFrame("messageStart", { role: "assistant" }),
|
||||
exceptionFrame("throttlingException", { message: "Slow down" }),
|
||||
])
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["throttlingException", { message: "Slow down" }],
|
||||
)
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
|
||||
@@ -685,7 +460,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(exceptionFrame("validationException", { message: "Input is too long for requested model" })),
|
||||
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
@@ -698,44 +473,12 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses originalMessage from model stream exception frames", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
exceptionFrame("modelStreamErrorException", {
|
||||
originalMessage: "Upstream model failed",
|
||||
originalStatusCode: 500,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails unmodeled AWS event-stream errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(errorFrame("BadStream", "Stream failed"))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "BadStream: Stream failed",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects requests with no auth path", () =>
|
||||
Effect.gen(function* () {
|
||||
const unsignedModel = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
|
||||
const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
@@ -754,7 +497,7 @@ describe("Bedrock Converse route", () => {
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
|
||||
const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
|
||||
|
||||
expect(prepared.route).toBe("bedrock-converse")
|
||||
expect(prepared.model).toBe(signed)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
|
||||
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
@@ -36,27 +36,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes Gemini thinking options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
})
|
||||
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
@@ -261,7 +240,7 @@ describe("Gemini route", () => {
|
||||
id: "req_tool_choice_none",
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
toolChoice: { type: "none" },
|
||||
}),
|
||||
)
|
||||
@@ -394,16 +373,10 @@ describe("Gemini route", () => {
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: "STOP" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "STOP" },
|
||||
reason: "stop",
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -431,8 +404,8 @@ describe("Gemini route", () => {
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-start")
|
||||
@@ -522,8 +495,8 @@ describe("Gemini route", () => {
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
@@ -556,16 +529,10 @@ describe("Gemini route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
reason: "tool-calls",
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -589,8 +556,8 @@ describe("Gemini route", () => {
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -604,10 +571,7 @@ describe("Gemini route", () => {
|
||||
},
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -627,41 +591,9 @@ describe("Gemini route", () => {
|
||||
)
|
||||
|
||||
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(length.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "length", raw: "MAX_TOKENS" },
|
||||
})
|
||||
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
|
||||
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(filtered.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "content-filter", raw: "SAFETY" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["MODEL_ARMOR", "content-filter"],
|
||||
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
|
||||
["IMAGE_RECITATION", "content-filter"],
|
||||
["LANGUAGE", "content-filter"],
|
||||
["UNEXPECTED_TOOL_CALL", "error"],
|
||||
["NO_IMAGE", "error"],
|
||||
["IMAGE_OTHER", "unknown"],
|
||||
["TOO_MANY_TOOL_CALLS", "error"],
|
||||
["MISSING_THOUGHT_SIGNATURE", "error"],
|
||||
["MALFORMED_RESPONSE", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
|
||||
),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
HttpOptions,
|
||||
LLM,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
Usage,
|
||||
} from "../../src"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -173,7 +162,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("adds native query params to the Chat Completions URL", () =>
|
||||
LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -193,7 +182,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
|
||||
LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
@@ -219,15 +208,15 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("applies serializable HTTP overlays after payload lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: model.route
|
||||
.with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
|
||||
.model({ id: model.id }),
|
||||
http: HttpOptions.make({
|
||||
http: {
|
||||
body: { metadata: { source: "test" } },
|
||||
headers: { authorization: "Bearer request", "x-custom": "yes" },
|
||||
query: { debug: "1" },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -580,16 +569,10 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
reason: "stop",
|
||||
usage,
|
||||
},
|
||||
])
|
||||
@@ -629,7 +612,7 @@ describe("OpenAI Chat route", () => {
|
||||
it.effect("parses and replays a configured custom reasoning field", () =>
|
||||
Effect.gen(function* () {
|
||||
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
|
||||
const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -649,7 +632,9 @@ describe("OpenAI Chat route", () => {
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model: custom, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -660,8 +645,8 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
|
||||
]
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -1033,8 +1018,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1052,14 +1037,8 @@ describe("OpenAI Chat route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: "tool_calls" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
|
||||
{ type: "finish", reason: "tool-calls", usage: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1076,8 +1055,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1098,8 +1077,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1116,8 +1095,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const error = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
@@ -1134,8 +1113,8 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
|
||||
)
|
||||
const input = LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
const input = LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const streamError = yield* LLMClient.stream(input).pipe(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
|
||||
import { LLM, Message, ToolCallPart } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||
@@ -53,9 +53,9 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
it.effect("prepares generic Chat target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
toolChoice: ToolChoice.make({ type: "required" }),
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
toolChoice: { type: "required" },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -232,10 +232,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "stop" },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { LLM } 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("Open Responses-compatible route", () => {
|
||||
it.effect("uses the Open Responses baseline for a configured deployment", () =>
|
||||
describe("OpenAI-compatible Responses route", () => {
|
||||
it.effect("reuses the OpenAI Responses protocol for a configured deployment", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(OpenAICompatibleResponses.route.body).toBe(OpenResponses.protocol.body)
|
||||
expect(OpenAICompatibleResponses.route.transport).toBe(OpenResponses.httpTransport)
|
||||
expect(OpenAICompatibleResponses.route.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body)
|
||||
expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -32,7 +27,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("openai-compatible-responses")
|
||||
expect(prepared.protocol).toBe("open-responses")
|
||||
expect(prepared.protocol).toBe("openai-responses")
|
||||
expect(prepared.model).toMatchObject({
|
||||
id: "example-model",
|
||||
provider: "example",
|
||||
@@ -50,67 +45,9 @@ describe("Open Responses-compatible 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" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
Usage,
|
||||
} from "../../src"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -107,7 +96,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers semantic service tier options", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
|
||||
const prepared = yield* LLMClient.prepare(input)
|
||||
|
||||
@@ -119,7 +108,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("passes through custom OpenAI reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
|
||||
@@ -129,7 +118,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
@@ -139,9 +128,9 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("flattens top-level object unions in function schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
{
|
||||
name: "read",
|
||||
description: "Read a path or resource.",
|
||||
inputSchema: {
|
||||
@@ -163,7 +152,7 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -218,7 +207,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("prepares OpenAI Responses WebSocket target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" }),
|
||||
@@ -302,7 +291,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("adds native query params to the Responses URL", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -324,7 +313,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
@@ -351,7 +340,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("loads OpenAI default auth from Effect Config", () =>
|
||||
LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -372,7 +361,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lets explicit auth override OpenAI default API key auth", () =>
|
||||
LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
LLM.updateRequest(request, {
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
auth: Auth.bearer("oauth-token"),
|
||||
@@ -867,13 +856,13 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "stop", raw: undefined },
|
||||
reason: "stop",
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: undefined },
|
||||
reason: "stop",
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
@@ -898,13 +887,11 @@ describe("OpenAI Responses route", () => {
|
||||
const length = yield* generate({ reason: "max_output_tokens" })
|
||||
const contentFilter = yield* generate({ reason: "content_filter" })
|
||||
const unknown = yield* generate({})
|
||||
const custom = yield* generate({ reason: "provider_limit" })
|
||||
|
||||
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason, custom.finishReason]).toEqual([
|
||||
{ normalized: "length", raw: "max_output_tokens" },
|
||||
{ normalized: "content-filter", raw: "content_filter" },
|
||||
{ normalized: "unknown", raw: undefined },
|
||||
{ normalized: "unknown", raw: "provider_limit" },
|
||||
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
|
||||
"length",
|
||||
"content-filter",
|
||||
"unknown",
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -959,8 +946,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
@@ -1005,7 +992,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
@@ -1051,8 +1038,8 @@ describe("OpenAI Responses route", () => {
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1060,7 +1047,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
@@ -1387,8 +1374,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
@@ -1435,16 +1422,10 @@ describe("OpenAI Responses route", () => {
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
usage,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "finish",
|
||||
reason: { normalized: "tool-calls", raw: undefined },
|
||||
reason: "tool-calls",
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
@@ -1473,8 +1454,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1484,7 +1465,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
@@ -1511,7 +1492,7 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
describe("OpenRouter", () => {
|
||||
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
|
||||
@@ -56,42 +54,6 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the upstream provider finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails on a mid-stream provider error", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
error: { code: 502, message: "Provider disconnected" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.message).toContain("Provider disconnected")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves manually supplied reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
|
||||
@@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({
|
||||
})
|
||||
|
||||
const expectCode = (response: LLMResponse) => {
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.finishReason).toBe("stop")
|
||||
expect(response.text.toUpperCase()).toContain(CODE)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("PDF recorded", () => {
|
||||
tools: { read_pdf: readPdfRuntime },
|
||||
}).pipe(Stream.runCollect),
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
|
||||
import {
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
ToolRuntime,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
toDefinitions,
|
||||
type ContentPart,
|
||||
type FinishReason,
|
||||
type LLMRequest,
|
||||
type Model,
|
||||
} from "../src"
|
||||
import { LLMClient } from "../src/route"
|
||||
@@ -91,7 +91,7 @@ const restroomImage = () =>
|
||||
export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const tools = { [weatherToolName]: weatherRuntimeTool }
|
||||
let next = LLMRequest.update(request, { tools: toDefinitions(tools) })
|
||||
let next = LLM.updateRequest(request, { tools: toDefinitions(tools) })
|
||||
const events: LLMEvent[] = []
|
||||
|
||||
for (let step = 0; step < 10; step++) {
|
||||
@@ -108,7 +108,7 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||
ToolRuntime.dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
|
||||
)
|
||||
events.push(...dispatched.flatMap(([, result]) => result.events))
|
||||
next = LLMRequest.update(next, {
|
||||
next = LLM.updateRequest(next, {
|
||||
messages: [
|
||||
...next.messages,
|
||||
Message.assistant(assistantContent(response.events)),
|
||||
@@ -123,8 +123,10 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
|
||||
|
||||
export const expectFinish = (events: ReadonlyArray<LLMEvent>, reason: FinishReason) =>
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
|
||||
export const expectFinish = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
|
||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
|
||||
|
||||
export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
@@ -134,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const finishes = events.filter(LLMEvent.is.finish)
|
||||
expect(finishes).toHaveLength(1)
|
||||
expect(finishes[0]?.reason.normalized).toBe("stop")
|
||||
expect(finishes[0]?.reason).toBe("stop")
|
||||
|
||||
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
|
||||
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
|
||||
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
|
||||
|
||||
const toolCalls = events.filter(LLMEvent.is.toolCall)
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
@@ -501,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
|
||||
continue
|
||||
}
|
||||
if (event.type === "finish") {
|
||||
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
|
||||
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
|
||||
}
|
||||
}
|
||||
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
|
||||
|
||||
@@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
|
||||
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
|
||||
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
|
||||
LLMEvent.textEnd({ id: "t1" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }),
|
||||
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
|
||||
]
|
||||
const response = LLMResponse.fromEvents(events)
|
||||
|
||||
expect(response?.finishReason).toEqual({ normalized: "stop" })
|
||||
expect(response?.finishReason).toBe("stop")
|
||||
expect(response?.usage).toMatchObject({ outputTokens: 5 })
|
||||
expect(response?.events).toEqual(events)
|
||||
expect(response?.events.map((event) => event.type)).toEqual([
|
||||
@@ -62,26 +62,18 @@ describe("LLMResponse reducer", () => {
|
||||
|
||||
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
|
||||
const withFinishUsage = LLMResponse.fromEvents([
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
|
||||
])
|
||||
const withoutFinishUsage = LLMResponse.fromEvents([
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
])
|
||||
|
||||
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
|
||||
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
|
||||
})
|
||||
|
||||
test("preserves the raw finish reason", () => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
|
||||
])
|
||||
|
||||
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
|
||||
})
|
||||
|
||||
test("assembles tool-call content only after the completed tool call event", () => {
|
||||
const pending = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
@@ -96,7 +88,7 @@ describe("LLMResponse reducer", () => {
|
||||
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
|
||||
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
])
|
||||
|
||||
expect(response?.message.content).toEqual([
|
||||
|
||||
@@ -48,12 +48,8 @@ describe("llm schema", () => {
|
||||
})
|
||||
|
||||
test("finish constructors accept usage input", () => {
|
||||
expect(
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
|
||||
).toBeInstanceOf(Usage)
|
||||
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
|
||||
Usage,
|
||||
)
|
||||
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
|
||||
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
|
||||
})
|
||||
|
||||
test("content part tagged union exposes guards", () => {
|
||||
|
||||
@@ -553,7 +553,7 @@ describe("LLMClient tools", () => {
|
||||
)
|
||||
|
||||
yield* TestToolRuntime.runTools({
|
||||
request: LLMRequest.update(baseRequest, {
|
||||
request: LLM.updateRequest(baseRequest, {
|
||||
model: AnthropicMessages.route
|
||||
.with({ auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id: "claude-sonnet-4-5" }),
|
||||
@@ -808,7 +808,7 @@ describe("LLMClient tools", () => {
|
||||
)
|
||||
const events = Array.from(
|
||||
yield* TestToolRuntime.runTools({
|
||||
request: LLMRequest.update(baseRequest, {
|
||||
request: LLM.updateRequest(baseRequest, {
|
||||
model: AnthropicMessages.route
|
||||
.with({ auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id: "claude-sonnet-4-5" }),
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
CodeModeURLSearchParams,
|
||||
} from "./values.js"
|
||||
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
|
||||
export type Services<T> = ServicesOf<T, []>
|
||||
|
||||
type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
|
||||
@@ -325,15 +323,12 @@ const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
||||
})
|
||||
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visibleTools = <R>(tools: Tools<R>) =>
|
||||
flattenTools(toolTrie(tools))
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(({ path, tool }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
}))
|
||||
flattenTools(toolTrie(tools)).map(({ path, tool }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
}))
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -404,7 +399,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score || compareText(left.entry.description.path, right.entry.description.path),
|
||||
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
|
||||
)
|
||||
.map(({ entry }) => entry)
|
||||
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
|
||||
|
||||
@@ -29,7 +29,7 @@ export type JsonSchema = {
|
||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Executable tool exposed through CodeMode's `tools` object. */
|
||||
/** Executable tool tool exposed through CodeMode's `tools` object. */
|
||||
export type Tool<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
|
||||
@@ -620,26 +620,6 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("renders equivalent catalogs identically regardless of tool insertion order", () => {
|
||||
const alpha = Tool.make({
|
||||
description: "Alpha tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Void,
|
||||
execute: () => Effect.void,
|
||||
})
|
||||
const zeta = Tool.make({
|
||||
description: "Zeta tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Void,
|
||||
execute: () => Effect.void,
|
||||
})
|
||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
const resolveLibrary = Tool.make({
|
||||
description: "Resolve a library ID",
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("blocked member names on tool paths", () => {
|
||||
})
|
||||
|
||||
test("tools may use blocked member names because path segments never touch real properties", async () => {
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["prototype", "issues.constructor", "nested.__proto__"])
|
||||
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
|
||||
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
|
||||
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
|
||||
@@ -182,7 +182,8 @@ describe("canonical path collisions", () => {
|
||||
"issues.close": echo("Close issue", "closed"),
|
||||
},
|
||||
})
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
// Catalog order follows first appearance of each canonical path.
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.list", "issues.get", "issues.close"])
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
||||
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
||||
|
||||
@@ -659,12 +659,12 @@ function streamPartEvents(
|
||||
return Effect.succeed([
|
||||
LLMEvent.stepFinish({
|
||||
index: state.step++,
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
reason: finishReason(event.finishReason),
|
||||
usage: usage(event.usage),
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||
reason: finishReason(event.finishReason),
|
||||
usage: usage(event.usage),
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
@@ -242,22 +242,23 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const pick = (items: typeof candidates) => {
|
||||
if (!Array.isReadonlyArrayNonEmpty(items)) return
|
||||
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
|
||||
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
|
||||
const selected = Array.min(
|
||||
return pipe(
|
||||
items,
|
||||
Order.mapInput(
|
||||
Order.Number,
|
||||
(item: (typeof candidates)[number]) =>
|
||||
(item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
|
||||
),
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => projectModel(item.model, provider)),
|
||||
Array.head,
|
||||
)
|
||||
return projectModel(selected.model, provider)
|
||||
}
|
||||
|
||||
const small = candidates.filter((item) => item.small)
|
||||
return pick(small.length > 0 ? small : candidates)
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeMode } from "../codemode"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, direct filesystem access, and timers are unavailable. Do not use \`fetch\`; all external access goes through \`tools\`.
|
||||
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, filesystem access, and timers are unavailable. Do not use \`fetch\`; all API calls go through \`tools\`.
|
||||
|
||||
Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`.
|
||||
|
||||
@@ -123,19 +126,34 @@ ${render(current)}`
|
||||
return full
|
||||
}
|
||||
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
|
||||
|
||||
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
|
||||
const catalog = CodeModeCatalog.summarize(entries ?? [])
|
||||
return Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
},
|
||||
})
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeModeInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("CodeModeInstructions.load")(function* (selection) {
|
||||
const entries = selection.info ? ((yield* codeMode.materialize(selection.info.permissions)).catalog ?? []) : []
|
||||
const catalog = CodeModeCatalog.summarize(entries)
|
||||
return Instructions.make<CodeModeCatalog.Summary>({
|
||||
key: Instructions.Key.make("core/codemode"),
|
||||
codec: Schema.toCodecJson(CodeModeCatalog.Summary),
|
||||
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CodeMode.node] })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CodeMode } from "./codemode"
|
||||
import { CodeModeInstructions } from "./codemode/instructions"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -81,6 +82,7 @@ const locationServiceNodes = [
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
CodeModeInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
|
||||
@@ -394,15 +394,19 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
}
|
||||
return toolHooks.hook.after((event) => {
|
||||
// Decode first so plugin mutations cannot alias the canonical outcome.
|
||||
const output = {
|
||||
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
|
||||
const output: Record<string, unknown> = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
messageID: event.messageID,
|
||||
callID: event.callID,
|
||||
input: event.input,
|
||||
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
|
||||
status: event.status,
|
||||
content: event.content,
|
||||
metadata: event.metadata,
|
||||
outputPaths: event.outputPaths,
|
||||
...(event.status === "error" ? { error: event.error } : {}),
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() => {
|
||||
@@ -413,16 +417,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
|
||||
return Effect.sync(() => {
|
||||
if (event.status === "completed" && decoded.value.status === "completed") {
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
if (output.content !== event.content) event.content = decoded.value.content
|
||||
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
|
||||
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
|
||||
return
|
||||
}
|
||||
if (event.status === "error" && decoded.value.status === "error") {
|
||||
event.error = decoded.value.error
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
if (output.error !== event.error) event.error = decoded.value.error
|
||||
if (output.content !== event.content) event.content = decoded.value.content
|
||||
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
|
||||
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as SessionContext from "./context"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { Database } from "../database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { InstructionDiscovery } from "../instruction-discovery"
|
||||
@@ -13,7 +12,7 @@ import { McpInstructions } from "../mcp/instructions"
|
||||
import { PluginSupervisor } from "../plugin/supervisor"
|
||||
import { ReferenceInstructions } from "../reference/instructions"
|
||||
import { SkillInstructions } from "../skill/instructions"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { AgentNotFoundError } from "./error"
|
||||
import { SessionHistory } from "./history"
|
||||
import { InstructionEntry } from "./instruction-entry"
|
||||
@@ -26,7 +25,6 @@ export interface Selection {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info }
|
||||
readonly instructions: Instructions.Instructions
|
||||
readonly toolSet: ToolRegistry.ToolSet
|
||||
}
|
||||
|
||||
export interface Loaded {
|
||||
@@ -35,17 +33,15 @@ export interface Loaded {
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly toolSet: ToolRegistry.ToolSet
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves model-request state in two phases: `select` fixes the Session,
|
||||
* agent, instruction sources, and tool snapshot; `load` adds the model and
|
||||
* active history for that selection. This module does not build or execute the
|
||||
* model request.
|
||||
* agent, and instruction sources; `load` adds the model and active history for
|
||||
* that selection. This module does not build or execute the model request.
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
|
||||
/** Selects the Session, agent, and instruction sources used by subsequent work. */
|
||||
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
/** Resolves the model and active history for that selection. */
|
||||
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
|
||||
@@ -59,6 +55,7 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const builtins = yield* InstructionBuiltIns.Service
|
||||
const codeModeInstructions = yield* CodeModeInstructions.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
@@ -69,7 +66,6 @@ const layer = Layer.effect(
|
||||
const referenceInstructions = yield* ReferenceInstructions.Service
|
||||
const skillInstructions = yield* SkillInstructions.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -80,32 +76,19 @@ const layer = Layer.effect(
|
||||
yield* plugins.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
toolSet: registry.snapshot(agent.info.permissions),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
references: referenceInstructions.load(),
|
||||
mcp: mcpInstructions.load(agent),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
const instructions = yield* Effect.all(
|
||||
[
|
||||
builtins.load(sessionID),
|
||||
codeModeInstructions.load(agent),
|
||||
discovery.load(),
|
||||
skillInstructions.load(agent),
|
||||
referenceInstructions.load(),
|
||||
mcpInstructions.load(agent),
|
||||
entries.load(sessionID),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return {
|
||||
session,
|
||||
agent: { ...agent, info: agent.info },
|
||||
instructions: Instructions.combine([
|
||||
loaded.builtins,
|
||||
CodeModeInstructions.make(loaded.toolSet.codeModeCatalog),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
loaded.references,
|
||||
loaded.mcp,
|
||||
loaded.entries,
|
||||
]),
|
||||
toolSet: loaded.toolSet,
|
||||
}
|
||||
).pipe(Effect.map(Instructions.combine))
|
||||
return { session, agent: { ...agent, info: agent.info }, instructions }
|
||||
})
|
||||
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
@@ -117,7 +100,6 @@ const layer = Layer.effect(
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.entries.map((entry) => entry.message),
|
||||
toolSet: selection.toolSet,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -130,6 +112,7 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
AgentV2.node,
|
||||
CodeModeInstructions.node,
|
||||
Database.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
@@ -141,6 +124,5 @@ export const node = makeLocationNode({
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
SkillInstructions.node,
|
||||
ToolRegistry.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SessionGenerate } from "./generate"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
@@ -23,6 +24,7 @@ export const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const app = yield* App.Metadata
|
||||
|
||||
return SessionGenerate.Service.of({
|
||||
@@ -34,7 +36,7 @@ export const layer = Layer.effect(
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
|
||||
? selection.session.id.slice(4)
|
||||
: selection.session.id
|
||||
const toolSet = selection.toolSet
|
||||
const toolSet = yield* registry.snapshot(selection.agent.info.permissions)
|
||||
const toolDefinitions = toolSet.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
@@ -87,5 +89,13 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
deps: [
|
||||
SessionContext.node,
|
||||
Database.node,
|
||||
PluginHooks.node,
|
||||
SessionRunnerModel.node,
|
||||
ToolRegistry.node,
|
||||
App.node,
|
||||
llmClient,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, LogLevel } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { ModelV2 } from "../model"
|
||||
@@ -10,7 +10,6 @@ import { PluginHooks } from "../plugin/hooks"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { SessionContext } from "./context"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
@@ -87,8 +86,8 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const app = yield* App.Metadata
|
||||
const promptCacheSnapshots = new Map<string, PromptCacheDiagnostics.Snapshot>()
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
const session = input.context.session
|
||||
@@ -99,7 +98,7 @@ export const layer = Layer.effect(
|
||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const toolSet = input.context.toolSet
|
||||
const toolSet = yield* registry.snapshot(agent.info.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
@@ -136,23 +135,6 @@ export const layer = Layer.effect(
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
if (yield* LogLevel.isEnabled("Debug")) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||
promptCacheSnapshots.delete(session.id)
|
||||
promptCacheSnapshots.set(session.id, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logDebug("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID: session.id,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return Effect.succeed({
|
||||
@@ -180,5 +162,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, App.node],
|
||||
deps: [PluginHooks.node, ToolRegistry.node, App.node],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionPending from "./pending"
|
||||
|
||||
import { and, asc, eq, or } from "drizzle-orm"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import {
|
||||
Compaction,
|
||||
@@ -26,13 +26,6 @@ type DatabaseService = Database.Interface["db"]
|
||||
|
||||
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
|
||||
|
||||
/**
|
||||
* Which pending input `promote` may consume: "steer" promotes steers only (a step
|
||||
* boundary mid-work), while "input" also allows one queued input when no steers are
|
||||
* waiting (the idle boundary, where the Session picks up fresh work).
|
||||
*/
|
||||
export type Promotable = "input" | "steer"
|
||||
|
||||
const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||
const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
@@ -362,32 +355,16 @@ export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseServ
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
/**
|
||||
* Which pending rows count: "any" counts every row including compaction, while
|
||||
* delivery scopes are blocked behind a pending compaction barrier. "input" means
|
||||
* any model-facing input, steered or queued.
|
||||
*/
|
||||
export type Scope = "any" | "input" | Delivery
|
||||
|
||||
export const has = Effect.fn("SessionPending.has")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
scope: Scope,
|
||||
delivery: Delivery,
|
||||
) {
|
||||
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
|
||||
if (yield* compaction(db, sessionID)) return false
|
||||
const row = yield* db
|
||||
.select({ id: SessionPendingTable.id })
|
||||
.from(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.session_id, sessionID),
|
||||
scope === "any"
|
||||
? undefined
|
||||
: scope === "input"
|
||||
? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
|
||||
: eq(SessionPendingTable.delivery, scope),
|
||||
),
|
||||
)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, delivery)))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -416,74 +393,66 @@ const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
|
||||
) {
|
||||
if (yield* compaction(db, sessionID)) return 0
|
||||
yield* Effect.forEach(
|
||||
rows,
|
||||
(row) => {
|
||||
const entry = fromRow(row)
|
||||
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
|
||||
return events
|
||||
.publish(SessionEvent.InputPromoted, {
|
||||
sessionID,
|
||||
inputID: entry.id,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
||||
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
return rows.length
|
||||
})
|
||||
|
||||
/**
|
||||
* Promotes pending input into visible messages and returns the promoted count.
|
||||
* Steers always go first; only the "input" scope may fall through to one queued
|
||||
* input, and it then collects steers that arrived during promotion.
|
||||
*/
|
||||
export const promote = Effect.fn("SessionPending.promote")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
scope: Promotable,
|
||||
) {
|
||||
return yield* inboxLocks.withLock(sessionID)(
|
||||
Effect.gen(function* () {
|
||||
if (yield* compaction(db, sessionID)) return 0
|
||||
const steers = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (steers.length > 0 || scope === "steer") return yield* publish(db, events, sessionID, steers)
|
||||
|
||||
const queued = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!queued) return 0
|
||||
const promoted = yield* publish(db, events, sessionID, [queued])
|
||||
const arrivedSteers = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return promoted + (yield* publish(db, events, sessionID, arrivedSteers))
|
||||
yield* Effect.forEach(
|
||||
rows,
|
||||
(row) => {
|
||||
const entry = fromRow(row)
|
||||
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
|
||||
return events
|
||||
.publish(SessionEvent.InputPromoted, {
|
||||
sessionID,
|
||||
inputID: entry.id,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
||||
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
return rows.length
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const promoteSteers = Effect.fn("SessionPending.promoteSteers")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* compaction(db, sessionID)) return 0
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* publish(db, events, sessionID, rows)
|
||||
})
|
||||
|
||||
export const promoteNextQueued = Effect.fn("SessionPending.promoteNextQueued")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* compaction(db, sessionID)) return false
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
||||
})
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
export * as PromptCacheDiagnostics from "./prompt-cache-diagnostics"
|
||||
|
||||
import type { LLMRequest } from "@opencode-ai/ai"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
interface Entry {
|
||||
readonly label: string
|
||||
readonly hash: string
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
readonly settings: string
|
||||
readonly tools: ReadonlyArray<Entry>
|
||||
readonly system: ReadonlyArray<Entry>
|
||||
readonly messages: ReadonlyArray<Entry>
|
||||
}
|
||||
|
||||
export type Comparison =
|
||||
| { readonly status: "initial" }
|
||||
| { readonly status: "stable"; readonly messages: number }
|
||||
| { readonly status: "append-only"; readonly previousMessages: number; readonly currentMessages: number }
|
||||
| {
|
||||
readonly status: "changed"
|
||||
readonly component: "settings" | "tools" | "system" | "messages"
|
||||
readonly index: number
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const hash = (value: unknown) => Hash.sha256(JSON.stringify(value)).slice(0, 16)
|
||||
|
||||
export function snapshot(request: LLMRequest): Snapshot {
|
||||
return {
|
||||
settings: hash({
|
||||
route: request.model.route.id,
|
||||
provider: request.model.provider,
|
||||
model: request.model.id,
|
||||
modelDefaults: request.model.defaults,
|
||||
compatibility: request.model.compatibility,
|
||||
routeDefaults: {
|
||||
generation: request.model.route.defaults.generation,
|
||||
providerOptions: request.model.route.defaults.providerOptions,
|
||||
http: request.model.route.defaults.http,
|
||||
},
|
||||
generation: request.generation,
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
toolChoice: request.toolChoice,
|
||||
cache: request.cache,
|
||||
}),
|
||||
tools: request.tools.map((tool) => ({ label: tool.name, hash: hash(tool) })),
|
||||
system: request.system.map((part, index) => ({ label: `system[${index}]`, hash: hash(part) })),
|
||||
messages: request.messages.map((message, index) => ({
|
||||
label: message.id ?? `${message.role}[${index}]`,
|
||||
hash: hash(message),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function compare(previous: Snapshot | undefined, current: Snapshot): Comparison {
|
||||
if (!previous) return { status: "initial" }
|
||||
if (previous.settings !== current.settings)
|
||||
return {
|
||||
status: "changed",
|
||||
component: "settings",
|
||||
index: 0,
|
||||
label: "model settings",
|
||||
}
|
||||
const tools = firstChange(previous.tools, current.tools, false)
|
||||
if (tools) return { status: "changed", component: "tools", ...tools }
|
||||
const system = firstChange(previous.system, current.system, false)
|
||||
if (system) return { status: "changed", component: "system", ...system }
|
||||
const messages = firstChange(previous.messages, current.messages, true)
|
||||
if (messages) return { status: "changed", component: "messages", ...messages }
|
||||
if (previous.messages.length === current.messages.length)
|
||||
return { status: "stable", messages: current.messages.length }
|
||||
return {
|
||||
status: "append-only",
|
||||
previousMessages: previous.messages.length,
|
||||
currentMessages: current.messages.length,
|
||||
}
|
||||
}
|
||||
|
||||
function firstChange(previous: ReadonlyArray<Entry>, current: ReadonlyArray<Entry>, allowAppend: boolean) {
|
||||
const index = previous.findIndex((entry, index) => entry.hash !== current[index]?.hash)
|
||||
if (index >= 0)
|
||||
return {
|
||||
index,
|
||||
label: current[index]?.label ?? previous[index]?.label ?? `entry[${index}]`,
|
||||
}
|
||||
if (current.length === previous.length || (allowAppend && current.length > previous.length)) return
|
||||
return {
|
||||
index: previous.length,
|
||||
label: current[previous.length]?.label ?? `entry[${previous.length}]`,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export type RunError =
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
/** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
|
||||
/** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */
|
||||
readonly drain: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
@@ -27,41 +28,6 @@ import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { SessionUsage } from "../usage"
|
||||
|
||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
||||
Retry: { readonly step: number }
|
||||
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
||||
}>
|
||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||
|
||||
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
||||
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some(
|
||||
(reason) =>
|
||||
Cause.isDieReason(reason) &&
|
||||
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
|
||||
)
|
||||
|
||||
/**
|
||||
* Classifies how the owned tool fibers ended. Interrupts and interactive declines abort
|
||||
* the step; a defect from a tool implementation becomes a failed tool call the model can
|
||||
* read; a typed infrastructure failure must fail the assistant and then the drain.
|
||||
*/
|
||||
const classifyToolExits = (settled: Exit.Exit<Array<Exit.Exit<void, ToolOutputStore.Error>>, never>) => {
|
||||
const causes =
|
||||
settled._tag === "Failure"
|
||||
? [settled.cause]
|
||||
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
const failure = causes.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||
return {
|
||||
interrupted: causes.some(Cause.hasInterrupts),
|
||||
declined: causes.some(isUserDeclined),
|
||||
failure,
|
||||
infraError: failure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(failure)),
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -77,126 +43,69 @@ const layer = Layer.effect(
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
const titleStarted = new Set<SessionSchema.ID>()
|
||||
const titleAttempted = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
/**
|
||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
||||
*/
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) {
|
||||
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
|
||||
do {
|
||||
yield* runSteps(input.sessionID)
|
||||
} while (yield* SessionPending.has(db, input.sessionID, "input"))
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return session
|
||||
})
|
||||
|
||||
/**
|
||||
* Runs logical steps until no tool result or newly admitted steer requires another
|
||||
* model call. Queued inputs remain pending until the current model work reaches idle.
|
||||
*/
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
|
||||
// Fresh work may promote queued input; later steps absorb steers only.
|
||||
let promotable: SessionPending.Promotable = "input"
|
||||
let step = 1
|
||||
while (true) {
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
yield* startTitleOnce(sessionID)
|
||||
yield* runPendingCompaction(sessionID)
|
||||
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
||||
promotable = "steer"
|
||||
step = result.step + 1
|
||||
}
|
||||
})
|
||||
|
||||
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
|
||||
const runStep = Effect.fnUntraced(function* (
|
||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionPending.Promotable,
|
||||
step: number,
|
||||
) {
|
||||
// Minting message identity before any attempt lets retries resume the same durable
|
||||
// message. A compaction restart re-mints: the old message is stranded behind the new
|
||||
// compaction boundary, so the rebuilt step needs identity inside the new epoch.
|
||||
let assistantMessageID = SessionMessage.ID.create()
|
||||
const retry = yield* Schedule.toStepWithSleep(
|
||||
SessionRunnerRetry.schedule(events, sessionID, () => assistantMessageID),
|
||||
)
|
||||
/**
|
||||
* Consumes one retry allowance: sleeps the scheduled backoff, or publishes
|
||||
* Step.Failed and fails once attempts are exhausted. The step loop performs
|
||||
* the retry itself on the next iteration.
|
||||
*/
|
||||
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
|
||||
retry(failure).pipe(
|
||||
Effect.as(CallOutcome.Retry({ step: failure.step })),
|
||||
Pull.catchDone(() =>
|
||||
events
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
error: failure.error,
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.fail(failure.cause))),
|
||||
),
|
||||
)
|
||||
let currentPromotable: SessionPending.Promotable | undefined = promotable
|
||||
let currentStep = step
|
||||
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
||||
let recoverOverflow = true
|
||||
while (true) {
|
||||
const outcome = yield* callModel(
|
||||
sessionID,
|
||||
currentPromotable,
|
||||
currentStep,
|
||||
recoverOverflow,
|
||||
assistantMessageID,
|
||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||
if (outcome._tag === "Restart") {
|
||||
if (outcome.recoveredOverflow) recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
for (const message of yield* store.context(sessionID)) {
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
callID: tool.id,
|
||||
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
}
|
||||
// Neither a retry nor a compaction restart re-promotes input.
|
||||
currentPromotable = undefined
|
||||
currentStep = outcome.step
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Prepares and runs at most one model call, executes its local tools, and durably
|
||||
* settles the step. Compaction may instead request that the logical step restart.
|
||||
*/
|
||||
const callModel = Effect.fn("SessionRunner.callModel")(function* (
|
||||
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
||||
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some(
|
||||
(reason) =>
|
||||
Cause.isDieReason(reason) &&
|
||||
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
|
||||
)
|
||||
|
||||
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionPending.Promotable | undefined,
|
||||
promotion: SessionPending.Delivery | undefined,
|
||||
step: number,
|
||||
recoverOverflow: boolean,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
recoverOverflow?: typeof compaction.compact,
|
||||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionPending.promote(db, events, selected.session.id, promotable) : 0
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, selected.session.id)
|
||||
if (promotion === "queue") {
|
||||
promoted += Number(yield* SessionPending.promoteNextQueued(db, events, selected.session.id))
|
||||
promoted += yield* SessionPending.promoteSteers(db, events, selected.session.id)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const loaded = yield* context.load(selected)
|
||||
const { session, agent } = loaded
|
||||
const session = loaded.session
|
||||
const agent = loaded.agent
|
||||
const resolved = loaded.model
|
||||
const model = resolved.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status === "completed")
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
return yield* new StepFailedError({ error: compacted.error })
|
||||
}
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
@@ -221,41 +130,7 @@ const layer = Layer.effect(
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
||||
tokens: settlement.tokens,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
Effect.gen(function* () {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// The stream is defined here but runs inside the settlement mask below: publish each
|
||||
// event durably, fork one fiber per local tool call, and hold back a virgin
|
||||
// context-overflow provider error so settlement may recover it via compaction.
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
@@ -297,10 +172,39 @@ const layer = Layer.effect(
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
|
||||
// Settle: only the stream itself is interruptible (restore); every line after it is
|
||||
// protected so a started call always reaches one durable outcome.
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
||||
tokens: settlement.tokens,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
Effect.gen(function* () {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
// Gather the evidence: how did the provider stream end?
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
|
||||
@@ -313,9 +217,10 @@ const layer = Layer.effect(
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
|
||||
.status === "completed"
|
||||
)
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
@@ -325,11 +230,9 @@ const layer = Layer.effect(
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
|
||||
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
|
||||
// Step.Started must be durable before the failure escapes.
|
||||
yield* serialized(publisher.startAssistant())
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
error,
|
||||
step: currentStep,
|
||||
})
|
||||
@@ -345,17 +248,30 @@ const layer = Layer.effect(
|
||||
const settled = yield* restore(
|
||||
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||
const tools = classifyToolExits(settled)
|
||||
const settledCauses =
|
||||
settled._tag === "Failure"
|
||||
? [settled.cause]
|
||||
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
|
||||
const userDeclined = settledCauses.some(isUserDeclined)
|
||||
|
||||
if (tools.declined || streamInterrupted || tools.interrupted) {
|
||||
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
}
|
||||
if (tools.failure !== undefined) {
|
||||
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the step still
|
||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||
// could not be persisted) also fails the assistant and then fails the drain.
|
||||
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||
const infraError =
|
||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||
if (settledFailure !== undefined) {
|
||||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const error = toSessionError(failure)
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
|
||||
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||
@@ -403,17 +319,68 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declined) return yield* Effect.interrupt
|
||||
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
|
||||
return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
if (userDeclined) return yield* Effect.interrupt
|
||||
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
||||
return yield* Effect.failCause(settledFailure)
|
||||
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return CallOutcome.Completed({ needsContinuation, step: currentStep })
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation,
|
||||
step: currentStep,
|
||||
} as const
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||
const runStep = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionPending.Delivery | undefined,
|
||||
step: number,
|
||||
) {
|
||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
||||
// overflow, so the recovery hook is dropped after it fires.
|
||||
let recoverOverflow: typeof compaction.compact | undefined = compaction.compact
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
while (true) {
|
||||
const attempt = yield* Effect.suspend(() =>
|
||||
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||
).pipe(
|
||||
Effect.tapError((error) =>
|
||||
error instanceof SessionRunnerRetry.RetryableFailure
|
||||
? Effect.sync(() => {
|
||||
currentStep = error.step
|
||||
assistantMessageID = error.assistantMessageID
|
||||
currentPromotion = undefined
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
|
||||
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
|
||||
return events
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: error.assistantMessageID,
|
||||
error: error.error,
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||
}),
|
||||
)
|
||||
if (attempt._tag === "Completed")
|
||||
return {
|
||||
needsContinuation: attempt.needsContinuation,
|
||||
step: attempt.step,
|
||||
}
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
currentPromotion = undefined
|
||||
currentStep = attempt.step
|
||||
}
|
||||
})
|
||||
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
@@ -432,53 +399,69 @@ const layer = Layer.effect(
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(compacted)) return
|
||||
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
||||
if (unsettled)
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
if (Exit.isFailure(compacted)) {
|
||||
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
||||
if (unsettled)
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
/** Closes stale tool calls left active by an earlier interrupted drain. */
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
for (const message of yield* store.context(sessionID)) {
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
callID: tool.id,
|
||||
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) {
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
|
||||
if (!input.force && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: SessionPending.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let shouldRun = input.force || hasSteer || hasQueue
|
||||
while (shouldRun) {
|
||||
let needsContinuation = true
|
||||
let step = 1
|
||||
// Repeat steps while continuation is needed. A step needs continuation only
|
||||
// when it recorded local tool calls whose results the model has not yet seen;
|
||||
// a provider error suppresses it. Pending steers also continue the loop so
|
||||
// interjections are answered before the session goes idle.
|
||||
while (needsContinuation) {
|
||||
const result = yield* runStep(input.sessionID, promotion, step)
|
||||
// Steer/queue promotion inside runStep has already made the pending input a visible
|
||||
// user message by this point, so the first-user-message check below is reliable.
|
||||
if (!titleAttempted.has(input.sessionID)) {
|
||||
titleAttempted.add(input.sessionID)
|
||||
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
||||
}
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
needsContinuation = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
|
||||
shouldRun = hasSteer || hasQueue
|
||||
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
}
|
||||
})
|
||||
|
||||
/** Fires title generation once per process after the first step makes a user message visible. */
|
||||
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
if (titleStarted.has(sessionID)) return
|
||||
titleStarted.add(sessionID)
|
||||
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
|
||||
})
|
||||
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return session
|
||||
})
|
||||
|
||||
return Service.of({ drain })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect } from "effect"
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
@@ -12,6 +12,7 @@ import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { MAX_BYTES } from "../../tool-output-store"
|
||||
import type { ToolRegistry } from "../../tool/registry"
|
||||
|
||||
type Input = {
|
||||
@@ -20,18 +21,16 @@ type Input = {
|
||||
readonly model: ModelV2.Ref
|
||||
readonly providerMetadataKey: string
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly assistantMessageID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
const content = Tool.nonEmpty(result.value)
|
||||
if (content !== undefined) return content
|
||||
}
|
||||
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
|
||||
if (result.type === "content" && result.value.length > 0)
|
||||
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
@@ -48,9 +47,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
progress?: ToolRegistry.Progress
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
|
||||
if (!tool.progress) return {}
|
||||
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
|
||||
return metadata === undefined ? {} : { metadata }
|
||||
}
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
@@ -58,13 +60,14 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (stepStarted) return assistantMessageID
|
||||
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID ??= SessionMessage.ID.create()
|
||||
stepStarted = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -76,7 +79,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return assistantMessageID
|
||||
})
|
||||
const currentAssistantMessageID = () =>
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
assistantMessageID === undefined
|
||||
? Effect.die(new Error("Tool event before assistant step start"))
|
||||
: Effect.succeed(assistantMessageID)
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||
const fragments = (
|
||||
name: string,
|
||||
@@ -287,7 +292,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
@@ -400,12 +405,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
tool.settled = true
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if (event.result.type === "error") {
|
||||
if (error !== undefined || event.result.type === "error") {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
executed,
|
||||
resultState,
|
||||
@@ -446,8 +451,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
||||
if (event.reason === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
return
|
||||
@@ -466,12 +471,13 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
|
||||
tool.progress = update
|
||||
const current = { ...update }
|
||||
tool.progress = current
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
metadata: update,
|
||||
metadata: current,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import { EventV2 } from "../../event"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { SessionRunner } from "./index"
|
||||
|
||||
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
|
||||
readonly cause: LLMError
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: SessionError.Error
|
||||
readonly step: number
|
||||
}> {}
|
||||
@@ -41,20 +43,24 @@ const retryAfter = (failure: RetryableFailure) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID, assistantMessageID: () => SessionMessage.ID) =>
|
||||
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
|
||||
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
Schedule.setInputType<RetryableFailure>(),
|
||||
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
|
||||
Schedule.passthrough,
|
||||
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
|
||||
const minimum = retryAfter(failure)
|
||||
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
|
||||
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: assistantMessageID(),
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
}),
|
||||
metadata.input instanceof RetryableFailure
|
||||
? events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: metadata.input.assistantMessageID,
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,16 +25,11 @@ export const Input = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Entry)
|
||||
type EncodedOutput = typeof Output.Encoded
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search results into the concise line-oriented output models expect. */
|
||||
export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
const lines = entries.length === 0 ? ["No files found"] : entries.map((item) => item.path)
|
||||
if (truncated)
|
||||
lines.push(
|
||||
"",
|
||||
`(Results are truncated: showing first ${entries.length} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -79,12 +74,11 @@ export const Plugin = {
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
return yield* ripgrep
|
||||
.glob({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
limit: limit + 1,
|
||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
@@ -96,15 +90,13 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
)
|
||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
output: result.entries,
|
||||
content: toModelContent(
|
||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
result.truncated,
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||
metadata: { count: output.length },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
@@ -82,11 +81,12 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
const fail = (path: string, error?: unknown) => {
|
||||
const prefix =
|
||||
applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix, error })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -101,7 +101,11 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
return yield* new ToolFailure({ message: "patch verification failed: no hunks found" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
@@ -141,7 +145,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -157,7 +161,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -171,7 +175,7 @@ export const Plugin = {
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -180,8 +184,7 @@ export const Plugin = {
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
@@ -208,13 +211,7 @@ export const Plugin = {
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error))))
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
@@ -237,16 +234,12 @@ export const Plugin = {
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
||||
)
|
||||
yield* fs.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
@@ -255,11 +248,7 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
.remove(change.target.canonical)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||
)
|
||||
yield* fs.remove(change.target.canonical)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
@@ -268,15 +257,8 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs.remove(change.target.canonical).pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
)
|
||||
yield* fs.writeWithDirs(change.moveTarget.canonical, change.content)
|
||||
yield* fs.remove(change.target.canonical)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.moveTarget.resource,
|
||||
@@ -284,15 +266,13 @@ export const Plugin = {
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.writeWithDirs(change.target.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
yield* fs.writeWithDirs(change.target.canonical, change.content)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
}),
|
||||
}).pipe(Effect.mapError((error) => fail(change.path, error))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
@@ -302,11 +282,7 @@ export const Plugin = {
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
||||
),
|
||||
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))),
|
||||
)
|
||||
},
|
||||
}),
|
||||
@@ -330,14 +306,6 @@ export const Plugin = {
|
||||
}),
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof PlatformError) {
|
||||
if (error.reason._tag === "NotFound") return "file does not exist"
|
||||
return error.reason.description ?? error.reason.message
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
|
||||
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { CodeModeCatalog } from "../codemode/catalog"
|
||||
import { Image } from "../image"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
@@ -45,13 +44,12 @@ export interface Interface {
|
||||
}
|
||||
|
||||
/**
|
||||
* One request-scoped snapshot pairing the Code Mode catalog and advertised
|
||||
* definitions with captured tools. A model request executes exactly the tool
|
||||
* values it advertised even if registration changes while it is in flight.
|
||||
* One request-scoped snapshot pairing advertised definitions with captured
|
||||
* tools. A model request executes exactly the tool values it advertised
|
||||
* even if registration changes while the request is in flight.
|
||||
*/
|
||||
export interface ToolSet {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
|
||||
}
|
||||
|
||||
@@ -322,17 +320,10 @@ const registryLayer = Layer.effect(
|
||||
if (whollyDisabled(registration.permission, rules)) continue
|
||||
direct.set(name, registration)
|
||||
}
|
||||
const codeModeMaterialization = yield* codeMode.materialize(permissions)
|
||||
const codemodeTool = codeModeMaterialization.tool
|
||||
const codemodeTool = (yield* codeMode.materialize(permissions)).tool
|
||||
return {
|
||||
...(codeModeMaterialization.catalog === undefined
|
||||
? {}
|
||||
: { codeModeCatalog: codeModeMaterialization.catalog }),
|
||||
definitions: [
|
||||
// Definitions are prompt-cache prefix bytes, so order only after effective registrations settle.
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([name, registration]) => toLLMDefinition(name, registration.tool)),
|
||||
...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)),
|
||||
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: ExecuteInput) => {
|
||||
|
||||
@@ -299,7 +299,6 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
})
|
||||
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ describe("CodeModeInstructions.render", () => {
|
||||
test("describes the runtime and execution lifecycle concisely", () => {
|
||||
const instructions = render([lookup])
|
||||
expect(instructions).toContain("Run JavaScript to orchestrate tool calls and compose their results.")
|
||||
expect(instructions).toContain("Imports, direct filesystem access, and timers are unavailable.")
|
||||
expect(instructions).toContain("Do not use `fetch`; all external access goes through `tools`.")
|
||||
expect(instructions).toContain("Imports, filesystem access, and timers are unavailable.")
|
||||
expect(instructions).toContain("Do not use `fetch`; all API calls go through `tools`.")
|
||||
expect(instructions).toContain(
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
)
|
||||
|
||||
@@ -1,83 +1,97 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
const echo: CodeModeCatalog.Entry = {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build")))
|
||||
|
||||
const echo = {
|
||||
path: "notes.echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
}
|
||||
|
||||
const lookup: CodeModeCatalog.Entry = {
|
||||
const lookup = {
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order",
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
|
||||
}
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () => {
|
||||
let catalog: ReadonlyArray<CodeModeCatalog.Entry> | undefined = [echo]
|
||||
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
|
||||
[
|
||||
CodeMode.node,
|
||||
Layer.mock(CodeMode.Service, {
|
||||
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { catalog }) }),
|
||||
register: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* CodeModeInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
expect(initialized.text).toContain("## Available tools")
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
|
||||
|
||||
const added = yield* readUpdate(CodeModeInstructions.make([echo, lookup]), initialized)
|
||||
catalog = [echo, lookup]
|
||||
const added = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
|
||||
expect(added.text).toContain("The Code Mode tool catalog has changed.")
|
||||
expect(added.text).toContain("New tools are available in addition to those previously listed:")
|
||||
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
|
||||
expect(added.text).not.toContain("## Available tools")
|
||||
|
||||
const removed = yield* readUpdate(CodeModeInstructions.make([echo]), { values: added.values })
|
||||
catalog = [echo]
|
||||
const removed = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, { values: added.values })))
|
||||
expect(removed.text).toBe(
|
||||
"The Code Mode tool catalog has changed.\n\n" +
|
||||
"The following tools are no longer available and must not be called: tools.orders.lookup.",
|
||||
)
|
||||
|
||||
expect(yield* readUpdate(CodeModeInstructions.make(), initialized)).toMatchObject({
|
||||
catalog = undefined
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
|
||||
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
|
||||
const alpha = Tool.make({
|
||||
description: "Alpha tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "alpha" }),
|
||||
})
|
||||
const zeta = Tool.make({
|
||||
description: "Zeta tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "zeta" }),
|
||||
})
|
||||
const layer = AppNodeBuilder.build(CodeMode.node)
|
||||
let catalog: ReadonlyArray<CodeModeCatalog.Entry> = [lookup, echo]
|
||||
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
|
||||
[
|
||||
CodeMode.node,
|
||||
Layer.mock(CodeMode.Service, {
|
||||
materialize: () => Effect.succeed({ catalog }),
|
||||
register: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
const initialized = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* codeMode.register(Tool.registrationEntries({ zeta, alpha }, { namespace: "tools" }))
|
||||
return yield* readInitial(CodeModeInstructions.make((yield* codeMode.materialize()).catalog))
|
||||
}),
|
||||
)
|
||||
const reordered = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* codeMode.register(Tool.registrationEntries({ alpha, zeta }, { namespace: "tools" }))
|
||||
return yield* readUpdate(CodeModeInstructions.make((yield* codeMode.materialize()).catalog), initialized)
|
||||
}),
|
||||
)
|
||||
const instructions = yield* CodeModeInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(reordered.changed).toBe(false)
|
||||
expect(reordered.text).toBe("")
|
||||
catalog = [echo, lookup]
|
||||
const update = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
|
||||
expect(update.changed).toBe(false)
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
LLMEvent.textDelta({ id: "generate", text: "OK" }),
|
||||
LLMEvent.textEnd({ id: "generate" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
|
||||
@@ -578,8 +578,7 @@ describe("LocationServiceMap", () => {
|
||||
const blockedState = yield* update(blocked.path, blockedID)
|
||||
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
|
||||
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
||||
const blockedTools = blockedState.tools.map((tool) => tool.name)
|
||||
expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
@@ -596,9 +595,7 @@ describe("LocationServiceMap", () => {
|
||||
const allowedState = yield* update(allowed.path, allowedID)
|
||||
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
|
||||
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
||||
const allowedTools = allowedState.tools.map((tool) => tool.name)
|
||||
expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
|
||||
expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
|
||||
@@ -246,35 +246,6 @@ describe("Patch", () => {
|
||||
).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
|
||||
})
|
||||
|
||||
test("appends a pure-addition chunk to a nonempty file", () => {
|
||||
expect(Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content).toBe(
|
||||
"line 1\nline 2\nadded 1\nadded 2\n",
|
||||
)
|
||||
})
|
||||
|
||||
test("applies a pure-addition chunk after an earlier replacement", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[
|
||||
{ oldLines: [], newLines: ["after-context", "second-line"] },
|
||||
{ oldLines: ["line1", "line2", "line3"], newLines: ["line1", "line2-replacement"] },
|
||||
],
|
||||
"line1\nline2\nline3\n",
|
||||
).content,
|
||||
).toBe("line1\nline2-replacement\nafter-context\nsecond-line\n")
|
||||
})
|
||||
|
||||
test("applies a deletion-only update chunk", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[{ oldLines: ["line1", "line2", "line3"], newLines: ["line1", "line3"] }],
|
||||
"line1\nline2\nline3\n",
|
||||
).content,
|
||||
).toBe("line1\nline3\n")
|
||||
})
|
||||
|
||||
test("updates empty files and adds a trailing newline", () => {
|
||||
expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe("First line\n")
|
||||
expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe("new\n")
|
||||
@@ -356,12 +327,6 @@ describe("Patch", () => {
|
||||
).toThrow("Failed to find expected lines")
|
||||
})
|
||||
|
||||
test("identifies a missing blank line", () => {
|
||||
expect(() =>
|
||||
Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n"),
|
||||
).toThrow("Failed to find an expected blank line in update.txt")
|
||||
})
|
||||
|
||||
test("parses an update without an explicit first chunk header", () => {
|
||||
expect(parse("*** Begin Patch\n*** Update File: file.txt\n import foo\n+bar\n*** End Patch")).toEqual([
|
||||
{
|
||||
@@ -448,14 +413,11 @@ describe("Patch", () => {
|
||||
|
||||
test("rejects invalid add and delete lines", () => {
|
||||
expect(() => parse("*** Begin Patch\n*** Add File: file.txt\nbad\n*** End Patch")).toThrow(
|
||||
"Invalid hunk at line 3: Invalid Add File line for 'file.txt': expected a line starting with '+', got 'bad'",
|
||||
"Invalid hunk at line 3: 'bad' is not a valid hunk header",
|
||||
)
|
||||
expect(() => parse("*** Begin Patch\n*** Delete File: file.txt\nbad\n*** End Patch")).toThrow(
|
||||
"Invalid hunk at line 3: Unexpected line after Delete File 'file.txt': 'bad'. Delete hunks do not contain body lines",
|
||||
"Invalid hunk at line 3: 'bad' is not a valid hunk header",
|
||||
)
|
||||
expect(() =>
|
||||
parse("*** Begin Patch\n*** Delete File: file.txt\n*** Frobnicate File: next.txt\n*** End Patch"),
|
||||
).toThrow("Invalid hunk at line 3: '*** Frobnicate File: next.txt' is not a valid hunk header")
|
||||
})
|
||||
|
||||
test("rejects an empty update hunk", () => {
|
||||
@@ -516,6 +478,6 @@ describe("Patch", () => {
|
||||
}
|
||||
expect(() =>
|
||||
parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: \n@@\n-old\n+new\n*** End Patch"),
|
||||
).toThrow("Invalid hunk at line 3: Move destination for 'old.txt' must not be empty")
|
||||
).toThrow("Invalid hunk at line 3: '*** Move to:' is not a valid hunk header")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -300,8 +300,8 @@ describe("PluginV2", () => {
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
|
||||
"context7_look_up",
|
||||
"plain",
|
||||
"context7_look_up",
|
||||
"execute",
|
||||
])
|
||||
}),
|
||||
@@ -365,7 +365,7 @@ describe("PluginV2", () => {
|
||||
yield* ctx.tool
|
||||
.hook("execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status === "completed") (event.content as unknown as unknown[]).splice(0)
|
||||
if (event.status === "completed") event.content = [] as never
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { GenerationOptions, LLM, LLMRequest, Message, Model, ToolDefinition } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
|
||||
|
||||
const model = Model.make({ id: "test", provider: "test", route: OpenAIChat.route })
|
||||
const tool = ToolDefinition.make({
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "System",
|
||||
prompt: "First",
|
||||
tools: [tool],
|
||||
})
|
||||
const compare = (current: LLMRequest) =>
|
||||
PromptCacheDiagnostics.compare(PromptCacheDiagnostics.snapshot(request), PromptCacheDiagnostics.snapshot(current))
|
||||
|
||||
describe("PromptCacheDiagnostics", () => {
|
||||
test("distinguishes initial and stable requests", () => {
|
||||
const snapshot = PromptCacheDiagnostics.snapshot(request)
|
||||
expect(PromptCacheDiagnostics.compare(undefined, snapshot)).toEqual({ status: "initial" })
|
||||
expect(PromptCacheDiagnostics.compare(snapshot, snapshot)).toEqual({ status: "stable", messages: 1 })
|
||||
})
|
||||
|
||||
test("recognizes append-only history", () => {
|
||||
const current = LLMRequest.update(request, { messages: [...request.messages, Message.assistant("Second")] })
|
||||
expect(compare(current)).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 2 })
|
||||
})
|
||||
|
||||
test("detects cache-sensitive setting changes", () => {
|
||||
const current = LLMRequest.update(request, { generation: GenerationOptions.make({ temperature: 0.5 }) })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "settings", index: 0, label: "model settings" })
|
||||
})
|
||||
|
||||
test("finds the first changed prefix component", () => {
|
||||
const changedTool = ToolDefinition.make({ ...tool, description: "Read one file" })
|
||||
const current = LLMRequest.update(request, { tools: [changedTool] })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "tools", index: 0, label: "read" })
|
||||
})
|
||||
|
||||
test("treats appended tools as a prefix change", () => {
|
||||
const write = ToolDefinition.make({
|
||||
name: "write",
|
||||
description: "Write a file",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
const current = LLMRequest.update(request, { tools: [...request.tools, write] })
|
||||
expect(compare(current)).toEqual({ status: "changed", component: "tools", index: 1, label: "write" })
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
reason: "stop",
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
@@ -60,7 +60,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: { normalized: "stop" },
|
||||
reason: "stop",
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
|
||||
text: "First",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
|
||||
const forked = yield* session.fork({ sessionID: parent.id })
|
||||
const parentContext = yield* session.context(parent.id)
|
||||
@@ -232,13 +232,13 @@ describe("SessionV2.create", () => {
|
||||
text: "Parent changed",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
yield* session.prompt({
|
||||
sessionID: forked.id,
|
||||
text: "Child continues",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, forked.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, forked.id)
|
||||
|
||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
@@ -263,13 +263,13 @@ describe("SessionV2.create", () => {
|
||||
text: "First",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
const second = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
text: "Second",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
@@ -414,7 +414,7 @@ describe("SessionV2.create", () => {
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, created.id, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, created.id)
|
||||
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||
@@ -440,7 +440,7 @@ describe("SessionV2.create", () => {
|
||||
text: "Replay lifecycle",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
|
||||
yield* SessionPending.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
|
||||
@@ -59,12 +59,8 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
|
||||
LLMEvent.textEnd({ id: "generate" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
usage: { inputTokens: 100, outputTokens: 10 },
|
||||
}),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
@@ -101,13 +97,6 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(ToolRegistry.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
codeModeCatalog: [
|
||||
{
|
||||
path: "captured.lookup",
|
||||
description: "Captured Code Mode catalog",
|
||||
signature: "tools.captured.lookup(input: {}): Promise<string>",
|
||||
},
|
||||
],
|
||||
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
|
||||
execute: () => Effect.die(new Error("unused")),
|
||||
}),
|
||||
@@ -296,14 +285,13 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
|
||||
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
|
||||
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
)
|
||||
expect(instructionUpdates).toHaveLength(1)
|
||||
expect(instructionUpdates?.[0]).toContain("Changed context")
|
||||
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
),
|
||||
).toEqual(["Changed context"])
|
||||
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
|
||||
@@ -216,7 +216,7 @@ describe("SessionV2.prompt", () => {
|
||||
text: "boundary",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
||||
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
@@ -248,7 +248,7 @@ describe("SessionV2.prompt", () => {
|
||||
text: "boundary",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
revert: { messageID: boundary.id, files: [] },
|
||||
@@ -448,7 +448,7 @@ describe("SessionV2.prompt", () => {
|
||||
|
||||
yield* session.prompt({ sessionID, text: "First", resume: false })
|
||||
yield* session.prompt({ sessionID, text: "Second", resume: false })
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
||||
@@ -625,7 +625,7 @@ describe("SessionV2.prompt", () => {
|
||||
})
|
||||
|
||||
yield* Effect.all(
|
||||
[SessionPending.promote(db, events, sessionID, "steer"), SessionPending.promote(db, events, sessionID, "steer")],
|
||||
[SessionPending.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
@@ -855,7 +855,7 @@ describe("SessionV2.prompt", () => {
|
||||
},
|
||||
})
|
||||
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{
|
||||
@@ -880,7 +880,7 @@ describe("SessionV2.prompt", () => {
|
||||
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
yield* SessionPending.promote(database.db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers(database.db, events, sessionID)
|
||||
const promotedRetry = yield* session.synthetic(input)
|
||||
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
|
||||
|
||||
@@ -892,7 +892,7 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps queued input pending until the idle boundary", () =>
|
||||
it.effect("keeps synthetic queue input pending until the queue boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -907,15 +907,9 @@ describe("SessionV2.prompt", () => {
|
||||
})
|
||||
|
||||
expect(input.delivery).toBe("queue")
|
||||
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer"),
|
||||
).toBe(0)
|
||||
expect(yield* SessionPending.promoteSteers(db, events, sessionID)).toBe(0)
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "input"),
|
||||
).toBe(1)
|
||||
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
|
||||
expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true)
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: input.id, type: "synthetic", text: "Queued completion" },
|
||||
])
|
||||
@@ -941,7 +935,7 @@ describe("SessionV2.prompt", () => {
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
|
||||
expect(
|
||||
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
|
||||
@@ -984,14 +978,10 @@ describe("SessionV2.pending", () => {
|
||||
{ id: second.id, type: "user", delivery: "steer" },
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "input"),
|
||||
).toBe(2)
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
|
||||
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "input"),
|
||||
).toBe(1)
|
||||
yield* SessionPending.promoteNextQueued(db, events, sessionID)
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -1003,12 +993,9 @@ describe("SessionV2.pending", () => {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const barrier = yield* session.compact({ sessionID })
|
||||
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
|
||||
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
|
||||
|
||||
yield* SessionPending.settleCompaction(db, { sessionID })
|
||||
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -45,7 +45,6 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
},
|
||||
providerMetadataKey,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -119,7 +118,9 @@ test("provider-executed success derives content and retains provider result stat
|
||||
test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const exit = await Effect.runPromiseExit(publisher.progress(call.id, { phase: "visible" }))
|
||||
const exit = await Effect.runPromiseExit(
|
||||
publisher.progress(call.id, { phase: "visible" }),
|
||||
)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
@@ -128,18 +129,6 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
||||
})
|
||||
})
|
||||
|
||||
test("failure snapshot retains canonical progress above the default byte limit", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const detail = "x".repeat(60 * 1024)
|
||||
await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { detail },
|
||||
})
|
||||
})
|
||||
|
||||
test("failure before progress omits partial output fields", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
@@ -256,7 +245,7 @@ test("success event data can carry provider-executed result state", () => {
|
||||
test("step finish records settlement without publishing step ended", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
|
||||
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
@@ -269,7 +258,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
publisher.publish(
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter" },
|
||||
reason: "content-filter",
|
||||
usage: {
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 3,
|
||||
@@ -312,7 +301,7 @@ test("content-filter finish preserves partial streamed text and never ends the s
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text" }),
|
||||
LLMEvent.textDelta({ id: "text", text: "Partial" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
],
|
||||
(event) => publisher.publish(event),
|
||||
{ discard: true },
|
||||
|
||||
@@ -121,33 +121,6 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const tool = make()
|
||||
const capture = (registrations: Parameters<typeof service.registerBatch>[0]) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.registerBatch(registrations)
|
||||
return (yield* service.snapshot()).definitions
|
||||
}),
|
||||
)
|
||||
const first = yield* capture([
|
||||
{ tools: { zeta: tool, alpha: tool }, options: { codemode: false } },
|
||||
{ tools: { beta: tool }, options: { namespace: "alpha", codemode: false } },
|
||||
{ tools: { echo: tool } },
|
||||
])
|
||||
const second = yield* capture([
|
||||
{ tools: { echo: tool } },
|
||||
{ tools: { beta: tool }, options: { namespace: "alpha", codemode: false } },
|
||||
{ tools: { alpha: tool, zeta: tool }, options: { codemode: false } },
|
||||
])
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(first.map((definition) => definition.name)).toEqual(["alpha", "alpha_beta", "zeta", "execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
@@ -169,7 +142,7 @@ describe("ToolRegistry", () => {
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -533,7 +506,6 @@ describe("ToolRegistry", () => {
|
||||
.pipe(Scope.provide(scope))
|
||||
const toolSet = yield* service.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
|
||||
expect(execute?.description).toContain("confined Code Mode runtime")
|
||||
expect(execute?.description).not.toContain("Echo text")
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
@@ -43,7 +43,6 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
@@ -120,8 +119,8 @@ const client = Layer.succeed(
|
||||
const reply = {
|
||||
stop: () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
],
|
||||
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
|
||||
textWithUsage: (text: string, id: string, inputTokens: number) =>
|
||||
@@ -137,8 +136,8 @@ const reply = {
|
||||
tool: (id: string, name: string, input: unknown) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id, name, input }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
}
|
||||
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||
@@ -369,12 +368,6 @@ const pluginSupervisor = Layer.succeed(
|
||||
flush: Effect.suspend(() => pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
let codeModeMaterializations: ReadonlyArray<CodeMode.Materialization> = []
|
||||
let codeModeMaterializationCount = 0
|
||||
const codeMode = Layer.mock(CodeMode.Service, {
|
||||
register: () => Effect.void,
|
||||
materialize: () => Effect.sync(() => codeModeMaterializations[codeModeMaterializationCount++] ?? {}),
|
||||
})
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(undefined),
|
||||
@@ -412,7 +405,6 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[ToolOutputStore.node, toolOutputStore],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[CodeMode.node, codeMode],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -472,7 +464,6 @@ const it = testEffect(
|
||||
[Config.node, config],
|
||||
[ToolOutputStore.node, toolOutputStore],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[CodeMode.node, codeMode],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -521,8 +512,6 @@ const setup = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
pluginFlushHook = Effect.void
|
||||
codeModeMaterializations = []
|
||||
codeModeMaterializationCount = 0
|
||||
currentModel = model
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
@@ -693,8 +682,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
completeEvents: [
|
||||
...partialEvents,
|
||||
LLMEvent.textEnd({ id }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
],
|
||||
expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
|
||||
expectedContent,
|
||||
@@ -713,8 +702,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
|
||||
completeEvents: [
|
||||
...partialEvents,
|
||||
LLMEvent.reasoningEnd({ id }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
],
|
||||
expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
|
||||
expectedContent,
|
||||
@@ -834,52 +823,6 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("uses one Code Mode materialization per request for instructions and execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const executed: string[] = []
|
||||
const execute = (name: string) =>
|
||||
Tool.make({
|
||||
description: `Execute ${name}`,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.sync(() => executed.push(name)).pipe(Effect.as({ output: name })),
|
||||
})
|
||||
const catalog = (name: string) => [
|
||||
{
|
||||
path: `catalog.${name.toLowerCase()}`,
|
||||
description: `Code Mode catalog ${name}`,
|
||||
signature: `tools.catalog.${name.toLowerCase()}(input: {}): Promise<string>`,
|
||||
},
|
||||
]
|
||||
const session = yield* setup
|
||||
codeModeMaterializations = [
|
||||
{ catalog: catalog("A"), tool: execute("A") },
|
||||
{ catalog: catalog("B"), tool: execute("B") },
|
||||
{ catalog: catalog("C"), tool: execute("C") },
|
||||
{ catalog: catalog("D"), tool: execute("D") },
|
||||
]
|
||||
yield* admit(session, "Use Code Mode")
|
||||
responses = [reply.tool("call-execute", "execute", {}), reply.stop()]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(codeModeMaterializationCount).toBe(2)
|
||||
expect(requests[0]?.system.some((part) => part.text.includes("Code Mode catalog A"))).toBe(true)
|
||||
expect(requests[0]?.system.some((part) => part.text.includes("Code Mode catalog B"))).toBe(false)
|
||||
expect(requests[0]?.tools.find((tool) => tool.name === "execute")?.description).toBe("Execute A")
|
||||
expect(executed).toEqual(["A"])
|
||||
expect(requests[1]?.tools.find((tool) => tool.name === "execute")?.description).toBe("Execute B")
|
||||
expect(
|
||||
requests[1]?.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some((part) => part.type === "text" && part.text.includes("Code Mode catalog B")),
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies session context hooks without exposing unavailable tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1043,7 +986,9 @@ describe("SessionRunnerLLM", () => {
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })),
|
||||
Effect.sync(() => executions.push("advertised")).pipe(
|
||||
Effect.as({ output: { value: "advertised" } }),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
@@ -1054,8 +999,8 @@ describe("SessionRunnerLLM", () => {
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
@@ -1072,7 +1017,9 @@ describe("SessionRunnerLLM", () => {
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ output: { value: "replacement" } })),
|
||||
Effect.sync(() => executions.push("replacement")).pipe(
|
||||
Effect.as({ output: { value: "replacement" } }),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
@@ -1152,7 +1099,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Second" }] },
|
||||
@@ -2430,7 +2377,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls" },
|
||||
reason: "tool-calls",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 8,
|
||||
@@ -2439,13 +2386,13 @@ describe("SessionRunnerLLM", () => {
|
||||
cacheReadInputTokens: 2,
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use tools" },
|
||||
{
|
||||
@@ -2588,8 +2535,8 @@ describe("SessionRunnerLLM", () => {
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
@@ -2653,8 +2600,8 @@ describe("SessionRunnerLLM", () => {
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
@@ -2701,8 +2648,8 @@ describe("SessionRunnerLLM", () => {
|
||||
),
|
||||
])
|
||||
const final = Stream.fromIterable([
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
])
|
||||
responseStream = Stream.concat(
|
||||
initial,
|
||||
@@ -3102,7 +3049,7 @@ describe("SessionRunnerLLM", () => {
|
||||
streamFailure = undefined
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* session.wait(sessionID)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
|
||||
@@ -3114,7 +3061,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
yield* admit(session, "Recover interrupted tool")
|
||||
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -3171,7 +3118,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
yield* admit(session, "Recover interrupted hosted tool")
|
||||
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -3222,7 +3169,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
yield* admit(session, "Recover interrupted tool input")
|
||||
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
|
||||
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -3658,7 +3605,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Reject permission")
|
||||
responses = [
|
||||
reply.tool("call-permission", "permissionfail", {}),
|
||||
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })],
|
||||
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
@@ -4007,10 +3954,10 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textDelta({ id: "partial", text: "Partial" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter" },
|
||||
reason: "content-filter",
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
||||
}),
|
||||
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
||||
LLMEvent.finish({ reason: "content-filter" }),
|
||||
]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
|
||||
@@ -4043,8 +3990,8 @@ describe("SessionRunnerLLM", () => {
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "content-filter" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
|
||||
LLMEvent.finish({ reason: "content-filter" }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
@@ -4223,7 +4170,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries a model call without consuming the logical agent step", () =>
|
||||
it.effect("retries a physical attempt without consuming the logical agent step", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
@@ -4335,8 +4282,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw,
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
@@ -4432,8 +4379,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
@@ -4474,8 +4421,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
@@ -4583,8 +4530,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
responses = [
|
||||
malformed("call-first"),
|
||||
@@ -4618,8 +4565,8 @@ describe("SessionRunnerLLM", () => {
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]
|
||||
responses = [malformed("call-first"), malformed("call-at-limit")]
|
||||
|
||||
@@ -4780,8 +4727,8 @@ describe("SessionRunnerLLM", () => {
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-clean-end", "effect"),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
@@ -4905,8 +4852,8 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textStart({ id: "text-2" }),
|
||||
LLMEvent.textDelta({ id: "text-2", text: "Second" }),
|
||||
LLMEvent.textEnd({ id: "text-2" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
@@ -4959,8 +4906,8 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }),
|
||||
LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }),
|
||||
hostedCall("call-parsed", "hello"),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -47,7 +47,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
reason: "stop",
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
@@ -58,7 +58,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: { normalized: "stop" },
|
||||
reason: "stop",
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Schema } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -29,8 +28,6 @@ const sessionID = SessionV2.ID.make("ses_patch_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let denyAction: string | undefined
|
||||
let failRemoveTarget: string | undefined
|
||||
let failRemoveErrorTarget: string | undefined
|
||||
let failWriteTarget: string | undefined
|
||||
let readsBeforeEditApproval = 0
|
||||
let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
@@ -68,8 +65,6 @@ const reset = () => {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
failRemoveTarget = undefined
|
||||
failRemoveErrorTarget = undefined
|
||||
failWriteTarget = undefined
|
||||
readsBeforeEditApproval = 0
|
||||
editApproved = false
|
||||
afterEditApproval = () => Effect.void
|
||||
@@ -87,33 +82,8 @@ const filesystem = Layer.effect(
|
||||
}).pipe(Effect.andThen(fs.readFile(target))),
|
||||
remove: (target, options) => {
|
||||
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
|
||||
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "remove",
|
||||
description: "forced remove failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.remove(target, options)
|
||||
},
|
||||
writeWithDirs: (target, content, mode) => {
|
||||
if (failWriteTarget && path.basename(target) === failWriteTarget) {
|
||||
return Effect.fail(
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "FileSystem",
|
||||
method: "writeWithDirs",
|
||||
description: "forced write failure",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs.writeWithDirs(target, content, mode)
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
@@ -332,27 +302,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("moves a file without changing its contents", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(directory, "old.txt")
|
||||
const destination = path.join(directory, "moved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(source, "same\n"))
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n same\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "Success. Updated the following files:\nM moved.txt" }],
|
||||
})
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("same\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("moves a symlink without deleting its target", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -502,17 +451,10 @@ describe("PatchTool", () => {
|
||||
it.live("rejects an empty patch", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
for (const patchText of [
|
||||
"*** Begin Patch\n*** End Patch",
|
||||
" *** Begin Patch \n *** End Patch ",
|
||||
"<<EOF\n*** Begin Patch\n*** End Patch\nEOF",
|
||||
"*** Begin Patch\n*** Environment ID: remote\n*** End Patch",
|
||||
]) {
|
||||
expect(yield* executeTool(registry, call(patchText))).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patch rejected: empty patch" },
|
||||
})
|
||||
}
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patch rejected: empty patch" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -583,10 +525,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: Failed to find expected lines in unchanged.txt:\nmissing",
|
||||
},
|
||||
error: { message: expect.stringContaining("Failed to find expected lines") },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
|
||||
}),
|
||||
@@ -630,83 +569,12 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("identifies a missing delete target", () =>
|
||||
it.live("rejects a delete when the target file is missing", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: Failed to delete missing.txt: file does not exist",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports the failing destination and filesystem error", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
|
||||
failWriteTarget = "new.txt"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Failed to write new.txt: forced write failure" },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
|
||||
expect(yield* exists(path.join(directory, "new.txt"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports the successful prefix and filesystem error", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
failWriteTarget = "second.txt"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: first.txt\n+first\n*** Add File: second.txt\n+second\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "Failed to write second.txt: forced write failure. Completed before failure: first.txt",
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "first.txt"), "utf8"))).toBe("first\n")
|
||||
expect(yield* exists(path.join(directory, "second.txt"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a destination written before move removal fails", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "old.txt"), "before\n"))
|
||||
failRemoveErrorTarget = "old.txt"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "Wrote new.txt but failed to remove old.txt: forced remove failure",
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "old.txt"), "utf8"))).toBe("before\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "new.txt"), "utf8"))).toBe("after\n")
|
||||
).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -760,7 +628,7 @@ describe("PatchTool", () => {
|
||||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
@@ -777,24 +645,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves edit permission rejection", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.join(directory, "target.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
|
||||
denyAction = "edit"
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: target.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("treats a sibling path inside the project worktree as internal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -86,16 +86,13 @@ describe("search tools", () => {
|
||||
const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
|
||||
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
|
||||
|
||||
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
|
||||
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(glob.content).toHaveLength(1)
|
||||
expect(grep.content).toHaveLength(1)
|
||||
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
|
||||
const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
|
||||
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT + 2)
|
||||
expect(globText).toEndWith(
|
||||
`(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
|
||||
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("SubagentTool", () => {
|
||||
},
|
||||
})
|
||||
const database = yield* Database.Service
|
||||
yield* SessionPending.promote(database.db, events, parent.id, "steer")
|
||||
yield* SessionPending.promoteSteers(database.db, events, parent.id)
|
||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(synthetic).toHaveLength(1)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
|
||||
@@ -81,8 +81,8 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
|
||||
}
|
||||
|
||||
function monoBody(body: RunEntryBody): RunEntryBody {
|
||||
if (body.type === "none" || body.type === "text" || body.type === "markdown") return body
|
||||
if (body.type === "code") return textBody(body.content)
|
||||
if (body.type === "none" || body.type === "text") return body
|
||||
if (body.type === "code" || body.type === "markdown") return textBody(body.content)
|
||||
const snapshot = body.snapshot
|
||||
if (snapshot.kind === "code") return textBody(`${snapshot.title}\n${snapshot.content}`)
|
||||
if (snapshot.kind === "diff") {
|
||||
|
||||
@@ -104,8 +104,7 @@ type RunFooterOptions = {
|
||||
|
||||
export function resolveRunAgent(agents: RunAgent[], current: string | undefined) {
|
||||
const selectable = agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
if (current === undefined) return selectable.at(0)
|
||||
return selectable.find((agent) => agent.id === current)
|
||||
return selectable.find((agent) => agent.id === current) ?? selectable.at(0)
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
@@ -328,7 +327,6 @@ export class RunFooter implements FooterApi {
|
||||
providers: footer.providers,
|
||||
currentAgent: footer.currentAgent,
|
||||
currentAgentID: footer.currentAgentID,
|
||||
currentAgentExplicit: () => selectedAgentID() !== undefined,
|
||||
currentModel: footer.currentModel,
|
||||
variants: footer.variants,
|
||||
currentVariant: footer.currentVariant,
|
||||
|
||||
@@ -29,11 +29,10 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunFormBody } from "./footer.form"
|
||||
import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||
import { footerStatuslinePolicy } from "./footer.width"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { monoShortcut } from "./mono"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
@@ -80,7 +79,6 @@ type RunFooterViewProps = {
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentAgent: () => string
|
||||
currentAgentID: () => string | undefined
|
||||
currentAgentExplicit: () => boolean
|
||||
currentModel: () => RunInput["model"]
|
||||
variants: () => string[]
|
||||
currentVariant: () => string | undefined
|
||||
@@ -118,6 +116,7 @@ type RunFooterViewProps = {
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const term = useTerminalDimensions()
|
||||
const width = createMemo(() => term().width)
|
||||
const responsive = createMemo(() => footerWidthPolicy(width()))
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const subagent = createMemo<FooterSubagentState>(() => {
|
||||
return (
|
||||
@@ -411,19 +410,19 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return shell() ? "Shell mode" : ""
|
||||
})
|
||||
const activityMeta = createMemo(() => {
|
||||
if (!footerDetails()) return ""
|
||||
if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
|
||||
})
|
||||
const agentStatus = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
|
||||
return props.currentAgent()
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = model() ?? props.state().model.trim()
|
||||
if (!footerDetails() || !prompt() || shell() || !current) return
|
||||
if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showModel || !current) return
|
||||
return {
|
||||
agent: props.currentAgent(),
|
||||
model: current,
|
||||
variant: props.currentVariant(),
|
||||
variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
|
||||
}
|
||||
})
|
||||
const statusColor = createMemo(() => {
|
||||
@@ -442,26 +441,32 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return theme().muted
|
||||
})
|
||||
const statuslineBackground = createMemo(() => theme().status)
|
||||
const contextHintCandidates = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell()) {
|
||||
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
|
||||
const hasModelStatus = createMemo(() => Boolean(modelStatus()))
|
||||
const contextHints = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items: Array<{ key: string; label: string }> = []
|
||||
const items: Array<{ kind: string; key: string; label: string }> = []
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
|
||||
return items
|
||||
const limit = responsive().statusline.contextHintLimit
|
||||
return limit === undefined ? items : items.slice(0, limit)
|
||||
})
|
||||
const hasContextHints = createMemo(() => contextHints().length > 0)
|
||||
const commandHint = createMemo(() => {
|
||||
if (!prompt()) return
|
||||
if (!prompt() || !responsive().statusline.showCommandHint) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shell()) {
|
||||
return { key: "esc", label: "normal" }
|
||||
@@ -471,49 +476,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return { key: command(), label: "cmd" }
|
||||
}
|
||||
})
|
||||
const commandHintWidth = createMemo(() => {
|
||||
const hint = commandHint()
|
||||
return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
|
||||
})
|
||||
const statuslineText = createMemo(() =>
|
||||
busy() && !exiting() && (footerDetails() || armed())
|
||||
? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
|
||||
: statusText(),
|
||||
)
|
||||
const statuslineMainWidth = createMemo(() => {
|
||||
const mode = modeLabel()
|
||||
const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
|
||||
const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
|
||||
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
|
||||
})
|
||||
const visibleModeLabel = createMemo(() => {
|
||||
const mode = modeLabel()
|
||||
if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
|
||||
return mode
|
||||
})
|
||||
const statuslineMainAvailable = createMemo(() => {
|
||||
const mode = visibleModeLabel()
|
||||
return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
|
||||
})
|
||||
const statuslineLayout = createMemo(() => {
|
||||
const agent = agentStatus()
|
||||
const info = modelStatus()
|
||||
return footerStatuslinePolicy({
|
||||
width: width(),
|
||||
mainWidth: statuslineMainWidth(),
|
||||
commandWidth: commandHint() ? commandHintWidth() : undefined,
|
||||
agentWidth: agent ? stringWidth(agent) : undefined,
|
||||
contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
|
||||
modelWidth: info ? stringWidth(info.model) : undefined,
|
||||
variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
|
||||
usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
|
||||
})
|
||||
})
|
||||
const contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
|
||||
const hasStatuslineInfo = createMemo(() => {
|
||||
const layout = statuslineLayout()
|
||||
return layout.showUsage || layout.showAgent || layout.showModel
|
||||
})
|
||||
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
|
||||
|
||||
createEffect(() => {
|
||||
@@ -914,7 +876,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
flexShrink={0}
|
||||
backgroundColor={statuslineBackground()}
|
||||
>
|
||||
<Show when={visibleModeLabel()}>
|
||||
<Show when={modeLabel()}>
|
||||
{(label) => (
|
||||
<box
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
@@ -934,21 +896,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
|
||||
paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
|
||||
minWidth={12}
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
footerDetails() &&
|
||||
busy() &&
|
||||
!exiting() &&
|
||||
statuslineMainAvailable() >=
|
||||
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
|
||||
}
|
||||
>
|
||||
<Show when={footerDetails() && busy() && !exiting()}>
|
||||
<box flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
@@ -964,36 +917,29 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={statuslineLayout().showUsage && activityMeta()}>
|
||||
{(usage) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().muted} wrapMode="none">
|
||||
{usage()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
<Show when={activityMeta().length > 0}>
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={1}>
|
||||
<text fg={theme().muted} wrapMode="none" truncate>
|
||||
{activityMeta()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={statuslineLayout().showAgent && agentStatus()}>
|
||||
{(agent) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
|
||||
{agent()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={statuslineLayout().showModel && modelStatus()}>
|
||||
<Show when={modelStatus()}>
|
||||
{(info) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
|
||||
{sectionSeparator()}
|
||||
<box
|
||||
minWidth={8}
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
flexShrink={1}
|
||||
>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={responsive().statusline.showAgent}>
|
||||
{info().agent}
|
||||
<span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
|
||||
</Show>
|
||||
{info().model}
|
||||
<Show when={statuslineLayout().showVariant && info().variant}>
|
||||
<Show when={info().variant}>
|
||||
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
|
||||
</Show>
|
||||
</text>
|
||||
@@ -1003,20 +949,25 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
<For each={contextHints()}>
|
||||
{(hint, index) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={index() > 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()}</Show>
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={24}>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint.label}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={commandHint()}>
|
||||
{(hint) => (
|
||||
<box backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint().label}</span>
|
||||
</text>
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
// Shared responsive width policy
|
||||
|
||||
const FOOTER_WIDTH_BREAKPOINTS = {
|
||||
commandHint: 24,
|
||||
model: 32,
|
||||
modelVariant: 40,
|
||||
compact: 80,
|
||||
context: 120,
|
||||
spacious: 150,
|
||||
} as const
|
||||
|
||||
export function footerWidthPolicy(width: number) {
|
||||
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
|
||||
const context = width >= FOOTER_WIDTH_BREAKPOINTS.context
|
||||
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
|
||||
|
||||
return {
|
||||
dialog: {
|
||||
narrow: width < 80,
|
||||
narrow: !compact,
|
||||
},
|
||||
statusline: {
|
||||
showActivityMeta: compact,
|
||||
showAgent: compact,
|
||||
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
|
||||
showModel: width >= FOOTER_WIDTH_BREAKPOINTS.model,
|
||||
showModelVariant: width >= FOOTER_WIDTH_BREAKPOINTS.modelVariant,
|
||||
showContextHints: compact,
|
||||
contextHintLimit: !compact ? 0 : spacious ? undefined : context ? 2 : 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const USAGE_HEADROOM = 8
|
||||
|
||||
export function footerStatuslinePolicy(input: {
|
||||
width: number
|
||||
mainWidth: number
|
||||
commandWidth?: number
|
||||
agentWidth?: number
|
||||
contextWidths: number[]
|
||||
modelWidth?: number
|
||||
variantWidth?: number
|
||||
usageWidth?: number
|
||||
}) {
|
||||
let remaining = input.width - input.mainWidth - (input.commandWidth ?? 0)
|
||||
let hasSection = input.commandWidth !== undefined
|
||||
const include = (width: number | undefined, headroom = 0) => {
|
||||
if (width === undefined) return false
|
||||
const required = width + (hasSection ? 3 : 1)
|
||||
if (remaining < required + headroom) return false
|
||||
remaining -= required
|
||||
hasSection = true
|
||||
return true
|
||||
}
|
||||
|
||||
const showModel = include(input.modelWidth)
|
||||
const showAgent = include(input.agentWidth)
|
||||
const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
|
||||
const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
|
||||
const contextComplete = contextCount === input.contextWidths.length
|
||||
const variantWidth = input.variantWidth
|
||||
const showVariant = showModel && contextComplete && variantWidth !== undefined && remaining >= variantWidth
|
||||
if (showVariant) remaining -= variantWidth
|
||||
const showUsage =
|
||||
(showModel || input.modelWidth === undefined) &&
|
||||
(showAgent || input.agentWidth === undefined) &&
|
||||
contextComplete &&
|
||||
(showVariant || input.variantWidth === undefined) &&
|
||||
include(input.usageWidth, USAGE_HEADROOM)
|
||||
|
||||
return {
|
||||
showAgent,
|
||||
contextCount,
|
||||
showModel,
|
||||
showVariant,
|
||||
showUsage,
|
||||
}
|
||||
}
|
||||
|
||||
+13
-122
@@ -1,17 +1,10 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
Renderable,
|
||||
StyledText,
|
||||
TextRenderable,
|
||||
TextTableRenderable,
|
||||
isStyledText,
|
||||
stringToStyledText,
|
||||
type BorderCharacters,
|
||||
type CliRendererExternalOutputEvent,
|
||||
type TreeSitterClient,
|
||||
type MarkdownOptions,
|
||||
type Renderable,
|
||||
} from "@opentui/core"
|
||||
|
||||
const prefixes: Record<number, string> = {
|
||||
@@ -59,129 +52,27 @@ const asciiBorder: BorderCharacters = {
|
||||
cross: "+",
|
||||
}
|
||||
|
||||
const hooked = new WeakSet<Renderable>()
|
||||
|
||||
export const monoMarkdownTableOptions = {
|
||||
style: "columns" as const,
|
||||
widthMode: "content" as const,
|
||||
borders: false,
|
||||
}
|
||||
|
||||
export function monoMarkdownRenderable(renderable: MarkdownRenderable): void {
|
||||
monoRenderable(renderable)
|
||||
export const monoMarkdownRenderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
|
||||
if (token.type !== "blockquote" && token.type !== "hr" && token.type !== "list") return
|
||||
const renderable = context.defaultRender()
|
||||
if (!renderable) return renderable
|
||||
monoBorders(renderable)
|
||||
return renderable
|
||||
}
|
||||
|
||||
function monoRenderable(renderable: Renderable): void {
|
||||
if (hooked.has(renderable)) return
|
||||
hooked.add(renderable)
|
||||
// Markdown reconciles nested lists and tables without calling renderNode.
|
||||
// Hook the actual tree so future descendants are transformed before layout.
|
||||
const add = renderable.add.bind(renderable)
|
||||
renderable.add = (child, index) => {
|
||||
if (child instanceof Renderable) monoRenderable(child)
|
||||
return add(child, index)
|
||||
}
|
||||
|
||||
function monoBorders(renderable: Renderable): void {
|
||||
if (renderable instanceof BoxRenderable) renderable.customBorderChars = asciiBorder
|
||||
if (renderable instanceof CodeRenderable) monoCode(renderable)
|
||||
if (renderable instanceof TextRenderable) renderable.content = monoStyledText(renderable.content)
|
||||
if (renderable instanceof TextTableRenderable) monoTable(renderable)
|
||||
renderable.getChildren().forEach(monoRenderable)
|
||||
renderable.getChildren().forEach(monoBorders)
|
||||
}
|
||||
|
||||
function monoCode(renderable: CodeRenderable): void {
|
||||
const onChunks = renderable.onChunks
|
||||
const prose = renderable.filetype === "markdown" && onChunks !== undefined
|
||||
renderable.onChunks = async (chunks, context) => monoChunks((await onChunks?.(chunks, context)) ?? chunks)
|
||||
renderable.treeSitterClient = monoTreeSitter(renderable.treeSitterClient)
|
||||
|
||||
const initialDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "initialStyledText")
|
||||
const contentDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "content")
|
||||
if (!initialDescriptor?.set || !contentDescriptor?.get || !contentDescriptor.set) return
|
||||
const initialSetter = initialDescriptor.set.bind(renderable)
|
||||
const contentGetter = contentDescriptor.get.bind(renderable)
|
||||
const contentSetter = contentDescriptor.set.bind(renderable)
|
||||
const initial = Reflect.get(renderable, "_initialStyledText")
|
||||
Object.defineProperty(renderable, "initialStyledText", {
|
||||
configurable: true,
|
||||
set(value: StyledText | undefined) {
|
||||
initialSetter(value ? monoStyledText(value) : value)
|
||||
},
|
||||
})
|
||||
Object.defineProperty(renderable, "content", {
|
||||
configurable: true,
|
||||
get: contentGetter,
|
||||
set(value: string) {
|
||||
if (!prose || !isStyledText(Reflect.get(renderable, "_initialStyledText"))) {
|
||||
renderable.drawUnstyledText = true
|
||||
renderable.initialStyledText = stringToStyledText(value)
|
||||
}
|
||||
contentSetter(value)
|
||||
},
|
||||
})
|
||||
|
||||
if (isStyledText(initial)) {
|
||||
renderable.initialStyledText = initial
|
||||
} else {
|
||||
renderable.drawUnstyledText = true
|
||||
renderable.initialStyledText = stringToStyledText(renderable.content)
|
||||
}
|
||||
if (!renderable.drawUnstyledText) return
|
||||
|
||||
// Refresh the eager buffer with the transformed initial text. Highlighted
|
||||
// chunks continue through onChunks without changing the Markdown source.
|
||||
const content = renderable.content
|
||||
renderable.content = ""
|
||||
renderable.content = content
|
||||
}
|
||||
|
||||
function monoTreeSitter(client: TreeSitterClient): TreeSitterClient {
|
||||
return new Proxy(client, {
|
||||
get(target, property) {
|
||||
if (property !== "highlightOnce") return Reflect.get(target, property, target)
|
||||
// Keep parser failures on the chunk path instead of OpenTUI's raw-text fallback.
|
||||
return (...args: Parameters<TreeSitterClient["highlightOnce"]>) =>
|
||||
target.highlightOnce(...args).catch(() => ({ highlights: [] }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function monoTable(renderable: TextTableRenderable): void {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(TextTableRenderable.prototype, "content")
|
||||
if (!descriptor?.get || !descriptor.set) return
|
||||
const cells = new WeakMap<StyledText["chunks"], StyledText["chunks"]>()
|
||||
const content = renderable.content
|
||||
Object.defineProperty(renderable, "content", {
|
||||
configurable: true,
|
||||
get: () => descriptor.get!.call(renderable),
|
||||
set: (value: TextTableRenderable["content"]) => {
|
||||
descriptor.set!.call(
|
||||
renderable,
|
||||
value.map((row) =>
|
||||
row.map((cell) => {
|
||||
if (!cell) return cell
|
||||
const cached = cells.get(cell)
|
||||
if (cached) return cached
|
||||
const next = monoChunks(cell)
|
||||
cells.set(cell, next)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
renderable.content = content
|
||||
}
|
||||
|
||||
function monoStyledText(value: StyledText): StyledText {
|
||||
return new StyledText(monoChunks(value.chunks))
|
||||
}
|
||||
|
||||
function monoChunks(value: StyledText["chunks"]): StyledText["chunks"] {
|
||||
return value.map((chunk) => ({ ...chunk, text: monoText(chunk.text) }))
|
||||
}
|
||||
|
||||
function monoText(value: string): string {
|
||||
export function monoMarkdown(value: string, mono: boolean): string {
|
||||
if (!mono) return value
|
||||
return value.replace(/[^\t\n\x20-\x7e]/gu, (char) => markdown[char.codePointAt(0)!] ?? "?")
|
||||
}
|
||||
|
||||
@@ -189,7 +80,7 @@ export function monoSnapshot(event: CliRendererExternalOutputEvent): void {
|
||||
const buffers = event.snapshot.buffers
|
||||
const chars = buffers.char
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
const point = chars[index]
|
||||
const point = chars[index]!
|
||||
if (point <= 0x7f) continue
|
||||
const offset = index * 4
|
||||
event.snapshot.setCell(
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type ScrollbackSurface,
|
||||
} from "@opentui/core"
|
||||
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
||||
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
|
||||
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { turnSummaryCommit } from "./turn-summary"
|
||||
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
|
||||
@@ -181,11 +181,11 @@ export class RunScrollbackStream {
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" },
|
||||
renderNode: this.mono ? monoMarkdownRenderNode : undefined,
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient,
|
||||
})
|
||||
|
||||
if (this.mono && renderable instanceof MarkdownRenderable) monoMarkdownRenderable(renderable)
|
||||
surface.root.add(renderable)
|
||||
|
||||
const rows = separatorRows(this.rendered, commit, body)
|
||||
@@ -283,7 +283,7 @@ export class RunScrollbackStream {
|
||||
}
|
||||
|
||||
const renderable = active.renderable
|
||||
renderable.content = active.content
|
||||
renderable.content = monoMarkdown(active.content, this.mono)
|
||||
renderable.streaming = !done
|
||||
await active.surface.settle()
|
||||
this.releasePendingThemes()
|
||||
@@ -378,7 +378,7 @@ export class RunScrollbackStream {
|
||||
) {
|
||||
await this.writeStreaming(commit, body)
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(entryFlags(commit).trailingNewline))
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
this.tail = commit
|
||||
return
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { createScrollbackWriter } from "@opentui/solid"
|
||||
import {
|
||||
MarkdownRenderable,
|
||||
TextRenderable,
|
||||
type ColorInput,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
|
||||
import { Match, Switch, createMemo } from "solid-js"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
|
||||
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
@@ -243,15 +237,13 @@ export function RunEntryContent(props: {
|
||||
</Match>
|
||||
<Match when={markdown()}>
|
||||
<markdown
|
||||
ref={(renderable: MarkdownRenderable) => {
|
||||
if (props.opts?.mono) monoMarkdownRenderable(renderable)
|
||||
}}
|
||||
width="100%"
|
||||
syntaxStyle={syntax()}
|
||||
streaming={streaming()}
|
||||
content={markdown()!.content}
|
||||
content={monoMarkdown(markdown()!.content, props.opts?.mono === true)}
|
||||
fg={color()}
|
||||
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
|
||||
renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -231,28 +231,29 @@ describe("run entry body", () => {
|
||||
})
|
||||
|
||||
test("promotes subagent results to markdown and falls back to structured summaries", () => {
|
||||
const result = toolCommit({
|
||||
tool: "subagent",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
description: "Inspect reducer",
|
||||
agent: "explore",
|
||||
},
|
||||
content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }],
|
||||
metadata: {
|
||||
sessionID: "ses-child-1",
|
||||
status: "completed",
|
||||
output: "# Findings\n\n- Footer stays live",
|
||||
},
|
||||
},
|
||||
})
|
||||
const markdown = {
|
||||
expect(
|
||||
entryBody(
|
||||
toolCommit({
|
||||
tool: "subagent",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
description: "Inspect reducer",
|
||||
agent: "explore",
|
||||
},
|
||||
content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }],
|
||||
metadata: {
|
||||
sessionID: "ses-child-1",
|
||||
status: "completed",
|
||||
output: "# Findings\n\n- Footer stays live",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "markdown",
|
||||
content: "# Findings\n\n- Footer stays live",
|
||||
} as const
|
||||
expect(entryBody(result)).toEqual(markdown)
|
||||
expect(entryBody(result, { mono: true })).toEqual(markdown)
|
||||
})
|
||||
|
||||
expect(
|
||||
structured(
|
||||
|
||||
@@ -49,7 +49,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
providers={() => undefined}
|
||||
currentAgent={() => "Build"}
|
||||
currentAgentID={() => "build"}
|
||||
currentAgentExplicit={() => false}
|
||||
currentModel={() => undefined}
|
||||
variants={() => []}
|
||||
currentVariant={() => undefined}
|
||||
|
||||
@@ -24,7 +24,7 @@ test("coalesces progress only within the same message and tool state", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back only when no agent is selected", () => {
|
||||
test("resolves the first selectable agent when none is selected", () => {
|
||||
const agents: RunAgent[] = [
|
||||
{ id: "task", name: "Task", mode: "subagent", hidden: false },
|
||||
{ id: "secret", name: "Secret", mode: "primary", hidden: true },
|
||||
@@ -34,5 +34,5 @@ test("falls back only when no agent is selected", () => {
|
||||
|
||||
expect(resolveRunAgent(agents, undefined)?.id).toBe("build")
|
||||
expect(resolveRunAgent(agents, "plan")?.id).toBe("plan")
|
||||
expect(resolveRunAgent(agents, "missing")).toBeUndefined()
|
||||
expect(resolveRunAgent(agents, "missing")?.id).toBe("build")
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user