mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 19:56:14 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ed5bb399 | ||
|
|
e48306e7e1 | ||
|
|
4f201f87a9 | ||
|
|
5aa276c117 | ||
|
|
4605308be2 | ||
|
|
c64d813347 | ||
|
|
6e4a972bb9 | ||
|
|
835149e42b | ||
|
|
0f3c30118c | ||
|
|
35d31d8ec1 | ||
|
|
0374d29232 | ||
|
|
d90da82be2 | ||
|
|
c06186a9d9 | ||
|
|
c5680a206e | ||
|
|
edaee143d9 | ||
|
|
4184149b90 | ||
|
|
00f063b381 | ||
|
|
ea010ab3a4 |
@@ -54,7 +54,7 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
|
||||
|
||||
A route is the registered, runnable composition of four orthogonal pieces:
|
||||
|
||||
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
|
||||
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
|
||||
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
|
||||
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
|
||||
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
|
||||
@@ -158,13 +158,14 @@ packages/ai/src/
|
||||
protocols/
|
||||
shared.ts ProviderShared toolkit used inside protocol impls
|
||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||
openai-responses.ts
|
||||
open-responses.ts provider-neutral Responses protocol baseline
|
||||
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
|
||||
anthropic-messages.ts
|
||||
gemini.ts
|
||||
bedrock-converse.ts
|
||||
bedrock-event-stream.ts framing for AWS event-stream binary frames
|
||||
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
|
||||
openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL
|
||||
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
|
||||
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
|
||||
providers/
|
||||
openai-compatible.ts generic Chat helper + family model helpers
|
||||
@@ -175,7 +176,7 @@ packages/ai/src/
|
||||
tool-runtime.ts narrow one-call typed tool dispatcher
|
||||
```
|
||||
|
||||
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
|
||||
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
|
||||
|
||||
### Shared protocol helpers
|
||||
|
||||
@@ -240,7 +241,7 @@ const get_weather = tool({
|
||||
|
||||
const tools = { get_weather, get_time, ... }
|
||||
const events = yield* LLM.stream(
|
||||
LLM.updateRequest(request, { tools: Tool.toDefinitions(tools) }),
|
||||
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
|
||||
@@ -315,7 +315,8 @@ const longer = {
|
||||
}
|
||||
```
|
||||
|
||||
There is no `LLM.updateRequest(...)` helper and no request Schema class.
|
||||
There is no `LLM.updateRequest(...)` helper. The current Schema-backed implementation
|
||||
uses `LLMRequest.update(...)` when canonical request data must be derived.
|
||||
|
||||
### Conversation history
|
||||
|
||||
@@ -436,7 +437,7 @@ const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
|
||||
if (call && !call.providerExecuted) {
|
||||
const dispatched = yield * ToolRuntime.dispatch(tools, call)
|
||||
const followUp = LLM.updateRequest(request, {
|
||||
const followUp = LLMRequest.update(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`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||
|
||||
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
|
||||
|
||||
|
||||
+24
-24
@@ -1,6 +1,6 @@
|
||||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-17
|
||||
Last reviewed: 2026-07-24
|
||||
|
||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||
|
||||
@@ -13,26 +13,26 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
|
||||
|
||||
## Current Implementation Snapshot
|
||||
|
||||
| Native slice | Source | Current state | Main gaps |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
||||
| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
|
||||
| Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
|
||||
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
|
||||
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
|
||||
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
|
||||
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
|
||||
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
|
||||
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
|
||||
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
|
||||
| Native slice | Source | Current state | Main gaps |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
||||
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
|
||||
| Vertex Responses | `src/protocols/open-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through Open Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and an explicit `store: false` Vertex default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
|
||||
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
|
||||
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
|
||||
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
|
||||
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
|
||||
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
|
||||
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
|
||||
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
|
||||
|
||||
## V2 Runner Status
|
||||
|
||||
@@ -65,7 +65,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
## Highest-Risk Gaps
|
||||
|
||||
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
|
||||
2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
|
||||
2. The Open Responses adapter is available through a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
|
||||
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
|
||||
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
|
||||
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
||||
@@ -83,13 +83,13 @@ These are implementation/API slices, not separate npm packages.
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
|
||||
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible Responses for Grok models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, LLMRequest, 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"
|
||||
|
||||
@@ -116,7 +116,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 = LLM.updateRequest(request, {
|
||||
const followUp = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
Message.assistant([event]),
|
||||
|
||||
+2
-20
@@ -9,24 +9,13 @@ 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],
|
||||
@@ -34,9 +23,9 @@ export type RequestInput = Omit<
|
||||
> & {
|
||||
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
|
||||
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
|
||||
readonly messages?: ReadonlyArray<Message | MessageInput>
|
||||
readonly messages?: ReadonlyArray<Message | Message.Input>
|
||||
readonly tools?: ReadonlyArray<ToolDefinition.Input>
|
||||
readonly toolChoice?: ToolChoiceInput
|
||||
readonly toolChoice?: ToolChoice.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
|
||||
readonly http?: HttpOptions.Input
|
||||
@@ -46,10 +35,6 @@ 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,
|
||||
@@ -74,9 +59,6 @@ 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,6 +13,7 @@ import {
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderOptions,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
@@ -31,6 +32,29 @@ 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
|
||||
// =============================================================================
|
||||
@@ -537,37 +561,38 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
|
||||
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 lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
||||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking)) return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "adaptive") {
|
||||
const display =
|
||||
thinking.display === "summarized"
|
||||
input.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: thinking.display === "omitted"
|
||||
: input.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (thinking.type === "disabled") return { type: "disabled" as const }
|
||||
if (thinking.type !== "enabled") return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
: typeof thinking.budget_tokens === "number"
|
||||
? thinking.budget_tokens
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("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
|
||||
@@ -587,8 +612,7 @@ 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
|
||||
@@ -603,6 +627,7 @@ 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,
|
||||
@@ -615,8 +640,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: yield* lowerThinking(request),
|
||||
output_config: outputConfig(request),
|
||||
thinking: options.thinking,
|
||||
output_config: options.effort === undefined ? undefined : { effort: options.effort },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -66,14 +66,15 @@ const BedrockToolResultBlock = Schema.Struct({
|
||||
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
|
||||
|
||||
const BedrockReasoningBlock = Schema.Struct({
|
||||
reasoningContent: Schema.Struct({
|
||||
reasoningText: Schema.optional(
|
||||
Schema.Struct({
|
||||
reasoningContent: Schema.Union([
|
||||
Schema.Struct({
|
||||
reasoningText: Schema.Struct({
|
||||
text: Schema.String,
|
||||
signature: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
Schema.Struct({ redactedContent: Schema.String }),
|
||||
]),
|
||||
})
|
||||
|
||||
const BedrockUserBlock = Schema.Union([
|
||||
@@ -154,6 +155,12 @@ 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
|
||||
@@ -181,6 +188,11 @@ 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),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -200,11 +212,11 @@ const BedrockEvent = Schema.Struct({
|
||||
metrics: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
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 })),
|
||||
internalServerException: Schema.optional(BedrockStreamException),
|
||||
modelStreamErrorException: Schema.optional(BedrockStreamException),
|
||||
validationException: Schema.optional(BedrockStreamException),
|
||||
throttlingException: Schema.optional(BedrockStreamException),
|
||||
serviceUnavailableException: Schema.optional(BedrockStreamException),
|
||||
})
|
||||
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
|
||||
@@ -260,6 +272,13 @@ 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,
|
||||
@@ -349,11 +368,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
content.push({
|
||||
reasoningContent: {
|
||||
reasoningText: { text: part.text, signature: reasoningSignature(part) },
|
||||
},
|
||||
})
|
||||
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 } } })
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -519,12 +540,26 @@ 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: reasoning.text
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
|
||||
: state.lifecycle,
|
||||
lifecycle,
|
||||
reasoningSignatures: reasoning.signature
|
||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
: state.reasoningSignatures,
|
||||
@@ -598,7 +633,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage)
|
||||
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -625,7 +660,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -53,8 +53,22 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
})
|
||||
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
|
||||
|
||||
if (decoded.headers[":message-type"]?.value !== "event") continue
|
||||
const eventType = decoded.headers[":event-type"]?.value
|
||||
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 (typeof eventType !== "string") continue
|
||||
const payload = utf8.decode(decoded.body)
|
||||
if (!payload) continue
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderOptions,
|
||||
type ProviderMetadata,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
@@ -26,6 +27,18 @@ 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
|
||||
// =============================================================================
|
||||
@@ -203,7 +216,9 @@ 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) => ({
|
||||
@@ -300,21 +315,22 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
|
||||
|
||||
const thinkingConfig = (request: LLMRequest) => {
|
||||
const value = geminiOptions(request)?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return undefined
|
||||
const result = {
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const value = request.providerOptions?.gemini?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return {}
|
||||
const thinkingConfig = {
|
||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
||||
}
|
||||
return Object.values(result).some((item) => item !== undefined) ? result : undefined
|
||||
return {
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : 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,
|
||||
@@ -322,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
topP: generation?.topP,
|
||||
topK: generation?.topK,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: thinkingConfig(request),
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,3 +6,4 @@ export * as OpenAIImages from "./openai-images"
|
||||
export * as OpenAICompatibleChat from "./openai-compatible-chat"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
export * as OpenAIResponses from "./openai-responses"
|
||||
export * as OpenResponses from "./open-responses"
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ReasoningPart,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "open-responses"
|
||||
const NAME = "Open Responses"
|
||||
const MEDIA_MIMES = new Set<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"
|
||||
@@ -396,14 +396,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
return {
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
@@ -434,7 +433,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stop: generation?.stop,
|
||||
...(yield* lowerOptions(request)),
|
||||
...lowerOptions(request),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { OpenAIResponses } from "./openai-responses"
|
||||
import { OpenResponses } from "./open-responses"
|
||||
|
||||
const ADAPTER = "openai-compatible-responses"
|
||||
|
||||
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
|
||||
|
||||
/**
|
||||
* Route for providers that expose an OpenAI Responses-compatible `/responses`
|
||||
* endpoint. Provider helpers configure identity, endpoint, and auth before
|
||||
* model selection while this route reuses the OpenAI Responses protocol.
|
||||
* Deployment adapter for providers that expose an Open Responses-compatible
|
||||
* `/responses` endpoint. Provider helpers configure identity, endpoint, and
|
||||
* auth while the semantic protocol remains provider-neutral.
|
||||
*/
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
providerMetadataKey: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenAIResponses.PATH),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
providerMetadataKey: "openresponses",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { Schema } from "effect"
|
||||
import { TextVerbosity, type LLMRequest } from "../../schema"
|
||||
|
||||
export const ResponseIncludables = [
|
||||
"file_search_call.results",
|
||||
"web_search_call.results",
|
||||
"web_search_call.action.sources",
|
||||
"message.input_image.image_url",
|
||||
"computer_call_output.output.image_url",
|
||||
"code_interpreter_call.outputs",
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs",
|
||||
] as const
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number]
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
const TEXT_VERBOSITY = new Set<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,85 +1,23 @@
|
||||
import { Schema } from "effect"
|
||||
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
||||
import { ReasoningEfforts } from "../../schema"
|
||||
import { OpenResponsesOptions } from "./open-responses-options"
|
||||
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts
|
||||
export type OpenAIReasoningEffort = string
|
||||
|
||||
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = [
|
||||
"file_search_call.results",
|
||||
"web_search_call.results",
|
||||
"web_search_call.action.sources",
|
||||
"message.input_image.image_url",
|
||||
"computer_call_output.output.image_url",
|
||||
"code_interpreter_call.outputs",
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs",
|
||||
] as const
|
||||
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
||||
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
|
||||
const TEXT_VERBOSITY = new Set<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 OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
|
||||
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
|
||||
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
|
||||
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
|
||||
const options = (request: LLMRequest) => request.providerOptions?.openai
|
||||
|
||||
export const store = (request: LLMRequest): boolean | undefined => {
|
||||
const value = options(request)?.store
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningEffort = (request: LLMRequest): string | undefined => {
|
||||
const value = options(request)?.reasoningEffort
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
||||
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
||||
|
||||
// Resolve the OpenAI Responses `include` field. Filters out unknown
|
||||
// includable values defensively so a typo in upstream config drops the
|
||||
// invalid entry instead of poisoning the wire body. An empty array (either
|
||||
// passed directly or produced by filtering) is treated as "no include" and
|
||||
// returns undefined so the request body omits the field entirely.
|
||||
export const include = (request: LLMRequest): ReadonlyArray<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 const resolve = OpenResponsesOptions.resolve
|
||||
|
||||
export * as OpenAIOptions from "./openai-options"
|
||||
|
||||
@@ -63,6 +63,8 @@ const openAI = (schema: JsonSchema): JsonSchema => {
|
||||
return isRecord(normalized) ? normalized : { type: "object" }
|
||||
}
|
||||
|
||||
const responses = openAI
|
||||
|
||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||
|
||||
const modelCompatibility = (
|
||||
@@ -83,4 +85,5 @@ export const ToolSchemaProjection = {
|
||||
modelCompatibility,
|
||||
moonshot,
|
||||
openAI,
|
||||
responses,
|
||||
} as const
|
||||
|
||||
@@ -5,12 +5,17 @@ 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 &
|
||||
@@ -20,6 +25,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
) & {
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
@@ -61,6 +67,7 @@ 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,11 +6,19 @@ 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 }
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
@@ -18,6 +26,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
@@ -52,5 +61,6 @@ 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,9 +6,13 @@ 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, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type ModelID } 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.
|
||||
@@ -19,6 +23,7 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
@@ -27,7 +32,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
providerOptions: { openresponses: { store: false } },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -4,9 +4,12 @@ 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, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type ModelID } 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 &
|
||||
@@ -14,6 +17,7 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
@@ -24,7 +28,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
|
||||
@@ -2,11 +2,13 @@ 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, type ProviderOptions } from "../schema"
|
||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } 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")
|
||||
|
||||
@@ -15,12 +17,13 @@ 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?: ProviderOptions
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options"
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||
|
||||
export interface OpenResponsesOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
readonly serviceTier?: ServiceTier
|
||||
}
|
||||
|
||||
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
|
||||
readonly openresponses?: OpenResponsesOptionsInput
|
||||
}
|
||||
|
||||
export * as OpenResponsesProviderOptions from "./open-responses-options"
|
||||
@@ -3,7 +3,9 @@ import { OpenAICompatibleResponses } from "../protocols/openai-compatible-respon
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||
|
||||
export type { OpenResponsesOptionsInput, OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
|
||||
@@ -11,13 +13,14 @@ export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleResponses.route]
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||
import type { ProviderOptions } from "../schema"
|
||||
import { mergeProviderOptions } from "../schema"
|
||||
import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
||||
|
||||
export interface OpenAIOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto"
|
||||
// OpenAI Responses `include` wire field. Mirrors the official SDK's
|
||||
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
|
||||
// native-SDK callers share one shape and no translation is required.
|
||||
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
}
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
|
||||
export type OpenAIProviderOptionsInput = ProviderOptions & {
|
||||
readonly openai?: OpenAIOptionsInput
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
||||
* Examples:
|
||||
*
|
||||
* - `OpenAIChat.protocol` — chat completions style
|
||||
* - `OpenAIResponses.protocol` — responses API
|
||||
* - `OpenResponses.protocol` — provider-neutral Responses API baseline
|
||||
* - `OpenAIResponses.protocol` — OpenAI extensions to that baseline
|
||||
* - `AnthropicMessages.protocol` — messages API with content blocks
|
||||
* - `Gemini.protocol` — generateContent
|
||||
* - `BedrockConverse.protocol` — Converse with binary event-stream framing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMResponse } from "../src"
|
||||
import { LLM, LLMRequest, LLMResponse } from "../src"
|
||||
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
|
||||
import { Model } from "../src/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -141,7 +141,7 @@ describe("llm route", () => {
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const prepared = yield* llm.prepare(
|
||||
LLM.updateRequest(request, { model: updateModel(request.model, { route: configuredGemini }) }),
|
||||
LLMRequest.update(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(
|
||||
LLM.updateRequest(request, { model: updateModel(request.model, { route: duplicate }) }),
|
||||
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
|
||||
)
|
||||
|
||||
expect(prepared.body).toEqual({ body: "late-default" })
|
||||
|
||||
@@ -137,15 +137,26 @@ 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" })
|
||||
@@ -159,10 +170,19 @@ 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" }).model("gemini-3.5-flash")
|
||||
GoogleVertex.configure({
|
||||
apiKey: "vertex-key",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
|
||||
}).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.
|
||||
@@ -208,7 +228,11 @@ GoogleVertexResponses.configure({
|
||||
project: "project",
|
||||
})
|
||||
|
||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
||||
GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "project",
|
||||
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" } },
|
||||
}).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,7 +11,13 @@ import {
|
||||
XAI,
|
||||
} from "@opencode-ai/ai/providers"
|
||||
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
|
||||
import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
import {
|
||||
OpenAIChat,
|
||||
OpenAICompatibleChat,
|
||||
OpenAICompatibleResponses,
|
||||
OpenAIResponses,
|
||||
OpenResponses,
|
||||
} from "@opencode-ai/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
|
||||
describe("public exports", () => {
|
||||
@@ -74,7 +80,9 @@ describe("public exports", () => {
|
||||
test("protocol barrels expose supported low-level routes", () => {
|
||||
expect(OpenAIChat.route.id).toBe("openai-chat")
|
||||
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
|
||||
expect(OpenResponses.protocol.id).toBe("open-responses")
|
||||
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
||||
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
||||
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
||||
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
|
||||
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
||||
|
||||
@@ -2,7 +2,16 @@ 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 { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
|
||||
import {
|
||||
GenerationOptions,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
} from "../src/schema"
|
||||
|
||||
const chatRoute = OpenAIChat.route
|
||||
const responsesRoute = OpenAIResponses.route
|
||||
@@ -31,8 +40,8 @@ describe("llm constructors", () => {
|
||||
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
const updated = LLM.updateRequest(base, {
|
||||
generation: { maxTokens: 20 },
|
||||
const updated = LLMRequest.update(base, {
|
||||
generation: GenerationOptions.make({ maxTokens: 20 }),
|
||||
messages: [...base.messages, Message.assistant("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: { openai: { reasoningEffort: "low", store: true } },
|
||||
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
@@ -72,7 +72,7 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
openai: { reasoningEffort: "low", store: true },
|
||||
openresponses: { reasoningEffort: "low", store: true },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,6 +85,7 @@ 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")
|
||||
@@ -96,6 +97,19 @@ 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 () => {
|
||||
@@ -235,12 +249,12 @@ describe("provider package entrypoints", () => {
|
||||
path: "/chat/completions",
|
||||
})
|
||||
expect(responses.route.id).toBe("google-vertex-responses")
|
||||
expect(responses.route.protocol).toBe("openai-responses")
|
||||
expect(responses.route.protocol).toBe("open-responses")
|
||||
expect(responses.route.endpoint).toMatchObject({
|
||||
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
||||
path: "/responses",
|
||||
})
|
||||
expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
|
||||
expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } })
|
||||
})
|
||||
|
||||
test("rejects conflicting Vertex auth settings at runtime", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, 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>(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
},
|
||||
@@ -74,6 +74,42 @@ 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>(
|
||||
@@ -512,8 +548,8 @@ describe("Anthropic Messages route", () => {
|
||||
// contents are provider-owned and must be replayed without inspection.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -551,7 +587,7 @@ describe("Anthropic Messages route", () => {
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
@@ -630,8 +666,8 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
@@ -815,8 +851,10 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -876,8 +914,10 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -993,7 +1033,10 @@ 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,7 +2,16 @@ 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, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
LLM,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
} from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
@@ -34,6 +43,26 @@ 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)
|
||||
@@ -86,7 +115,9 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
|
||||
LLMRequest.update(baseRequest, {
|
||||
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
|
||||
}),
|
||||
)
|
||||
|
||||
// Converse's inferenceConfig has no topK; Anthropic/Nova read it from
|
||||
@@ -123,13 +154,13 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("prepares tool config with toolSpec and toolChoice", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.updateRequest(baseRequest, {
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
{
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
||||
},
|
||||
}),
|
||||
],
|
||||
toolChoice: ToolChoice.make({ type: "required" }),
|
||||
}),
|
||||
@@ -157,13 +188,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(
|
||||
LLM.updateRequest(baseRequest, {
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
{
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
toolChoice: ToolChoice.make({ type: "none" }),
|
||||
}),
|
||||
@@ -352,6 +383,19 @@ 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(
|
||||
@@ -369,8 +413,8 @@ describe("Bedrock Converse route", () => {
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(baseRequest, {
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
@@ -467,12 +511,170 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies throttlingException as a rate limit", () =>
|
||||
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" }],
|
||||
["throttlingException", { message: "Slow down" }],
|
||||
[
|
||||
"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 error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
|
||||
@@ -483,7 +685,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
|
||||
fixedBytes(exceptionFrame("validationException", { message: "Input is too long for requested model" })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
@@ -496,12 +698,44 @@ 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(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
|
||||
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
@@ -520,7 +754,7 @@ describe("Bedrock Converse route", () => {
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
|
||||
const prepared = yield* LLMClient.prepare(LLMRequest.update(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, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
@@ -36,6 +36,27 @@ 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>(
|
||||
@@ -240,7 +261,7 @@ describe("Gemini route", () => {
|
||||
id: "req_tool_choice_none",
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
toolChoice: { type: "none" },
|
||||
}),
|
||||
)
|
||||
@@ -410,8 +431,8 @@ describe("Gemini route", () => {
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-start")
|
||||
@@ -501,8 +522,8 @@ describe("Gemini route", () => {
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
@@ -568,8 +589,8 @@ describe("Gemini route", () => {
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import {
|
||||
HttpOptions,
|
||||
LLM,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
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"
|
||||
@@ -162,7 +173,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("adds native query params to the Chat Completions URL", () =>
|
||||
LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -182,7 +193,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
|
||||
LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
@@ -208,15 +219,15 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("applies serializable HTTP overlays after payload lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: model.route
|
||||
.with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
|
||||
.model({ id: model.id }),
|
||||
http: {
|
||||
http: HttpOptions.make({
|
||||
body: { metadata: { source: "test" } },
|
||||
headers: { authorization: "Bearer request", "x-custom": "yes" },
|
||||
query: { debug: "1" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -618,7 +629,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(LLM.updateRequest(request, { model: custom })).pipe(
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -638,9 +649,7 @@ 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" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -651,8 +660,8 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
|
||||
]
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -1024,8 +1033,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1067,8 +1076,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1089,8 +1098,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
@@ -1107,8 +1116,8 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
@@ -1125,8 +1134,8 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
|
||||
)
|
||||
const input = LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
const input = LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ 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, Message, ToolCallPart } from "../../src"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } 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(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
toolChoice: { type: "required" },
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
toolChoice: ToolChoice.make({ type: "required" }),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses"
|
||||
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
describe("OpenAI-compatible Responses route", () => {
|
||||
it.effect("reuses the OpenAI Responses protocol for a configured deployment", () =>
|
||||
describe("Open Responses-compatible route", () => {
|
||||
it.effect("uses the Open Responses baseline for a configured deployment", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body)
|
||||
expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
expect(OpenAICompatibleResponses.route.body).toBe(OpenResponses.protocol.body)
|
||||
expect(OpenAICompatibleResponses.route.transport).toBe(OpenResponses.httpTransport)
|
||||
expect(OpenAICompatibleResponses.route.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -27,7 +32,7 @@ describe("OpenAI-compatible Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("openai-compatible-responses")
|
||||
expect(prepared.protocol).toBe("openai-responses")
|
||||
expect(prepared.protocol).toBe("open-responses")
|
||||
expect(prepared.model).toMatchObject({
|
||||
id: "example-model",
|
||||
provider: "example",
|
||||
@@ -45,9 +50,67 @@ describe("OpenAI-compatible Responses route", () => {
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("Open Responses does not support provider-native tool image_generation")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads standard options from the Open Responses namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "low" },
|
||||
store: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not interpret OpenAI hosted-tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "web_search_call", id: "ws_1", status: "completed", action: { query: "news" } },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
|
||||
providerMetadata: { openresponses: { responseId: "resp_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
|
||||
import {
|
||||
LLM,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Model,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
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"
|
||||
@@ -96,7 +107,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers semantic service tier options", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
|
||||
const prepared = yield* LLMClient.prepare(input)
|
||||
|
||||
@@ -108,7 +119,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("passes through custom OpenAI reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
|
||||
@@ -118,7 +129,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
|
||||
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
@@ -128,9 +139,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>(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
{
|
||||
ToolDefinition.make({
|
||||
name: "read",
|
||||
description: "Read a path or resource.",
|
||||
inputSchema: {
|
||||
@@ -152,7 +163,7 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -207,7 +218,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("prepares OpenAI Responses WebSocket target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" }),
|
||||
@@ -291,7 +302,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("adds native query params to the Responses URL", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -313,7 +324,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
@@ -340,7 +351,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("loads OpenAI default auth from Effect Config", () =>
|
||||
LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -361,7 +372,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lets explicit auth override OpenAI default API key auth", () =>
|
||||
LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
LLMRequest.update(request, {
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
auth: Auth.bearer("oauth-token"),
|
||||
@@ -889,12 +900,7 @@ describe("OpenAI Responses route", () => {
|
||||
const unknown = yield* generate({})
|
||||
const custom = yield* generate({ reason: "provider_limit" })
|
||||
|
||||
expect([
|
||||
length.finishReason,
|
||||
contentFilter.finishReason,
|
||||
unknown.finishReason,
|
||||
custom.finishReason,
|
||||
]).toEqual([
|
||||
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 },
|
||||
@@ -999,7 +1005,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
@@ -1054,7 +1060,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
@@ -1381,8 +1387,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
@@ -1467,8 +1473,8 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import {
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
ToolRuntime,
|
||||
@@ -11,7 +12,6 @@ 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 = LLM.updateRequest(request, { tools: toDefinitions(tools) })
|
||||
let next = LLMRequest.update(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 = LLM.updateRequest(next, {
|
||||
next = LLMRequest.update(next, {
|
||||
messages: [
|
||||
...next.messages,
|
||||
Message.assistant(assistantContent(response.events)),
|
||||
@@ -123,10 +123,8 @@ 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: FinishReason) =>
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
|
||||
|
||||
export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
|
||||
@@ -553,7 +553,7 @@ describe("LLMClient tools", () => {
|
||||
)
|
||||
|
||||
yield* TestToolRuntime.runTools({
|
||||
request: LLM.updateRequest(baseRequest, {
|
||||
request: LLMRequest.update(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: LLM.updateRequest(baseRequest, {
|
||||
request: LLMRequest.update(baseRequest, {
|
||||
model: AnthropicMessages.route
|
||||
.with({ auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id: "claude-sonnet-4-5" }),
|
||||
|
||||
@@ -72,7 +72,6 @@ Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: {
|
||||
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
```
|
||||
|
||||
@@ -145,13 +144,13 @@ safe refusal to the model; its optional cause remains private.
|
||||
|
||||
## Discovery
|
||||
|
||||
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with
|
||||
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is
|
||||
complete or partial.
|
||||
`runtime.catalog()` returns structured descriptors — exact path, description, and generated TypeScript signature — for
|
||||
every visible tool. Hosts render their own model-facing instructions from these descriptors; `CodeMode.searchSignature`
|
||||
and `CodeMode.toolExpression(path)` supply the exact callable forms.
|
||||
|
||||
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports
|
||||
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full
|
||||
signatures. Search counts toward `maxToolCalls`.
|
||||
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
|
||||
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
|
||||
`maxToolCalls`.
|
||||
|
||||
## Execution Limits
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
/** Signature-construction helpers for host-owned catalog instructions. */
|
||||
export { searchSignature, toolExpression } from "./tool-runtime.js"
|
||||
|
||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||
export type ExecutionLimits = {
|
||||
@@ -22,12 +24,6 @@ export type ExecutionLimits = {
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** Controls how much of the tool catalog is inlined in agent instructions. */
|
||||
export type DiscoveryOptions = {
|
||||
/** Approximate token budget (chars/4, default 2000) for full catalog entries. */
|
||||
readonly catalogBudget?: number
|
||||
}
|
||||
|
||||
export type ResolvedExecutionLimits = {
|
||||
readonly timeoutMs: number | undefined
|
||||
readonly maxToolCalls: number | undefined
|
||||
@@ -52,10 +48,7 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
|
||||
export type DataValue = Schema.Json
|
||||
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code"> & {
|
||||
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
|
||||
readonly discovery?: DiscoveryOptions
|
||||
}
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code">
|
||||
|
||||
/** Schema for a host tool input containing CodeMode source. */
|
||||
export const Input = Schema.Struct({ code: Schema.String })
|
||||
@@ -116,7 +109,6 @@ export type Result = typeof Result.Type
|
||||
/** Reusable confined runtime over explicit tools. */
|
||||
export type Runtime<R = never> = {
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
readonly instructions: () => string
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
@@ -147,11 +139,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
): Runtime<Services<Provided>> => {
|
||||
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||
const prepared = ToolRuntime.prepare(tools)
|
||||
|
||||
return {
|
||||
catalog: () => prepared.catalog,
|
||||
instructions: () => prepared.instructions,
|
||||
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * as CodeMode from "./codemode.js"
|
||||
export * as Tool from "./tool.js"
|
||||
export * as OpenAPI from "./openapi/index.js"
|
||||
export { searchSignature, toolExpression } from "./codemode.js"
|
||||
export { ToolError, toolError } from "./tool-error.js"
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
CodeModeURLSearchParams,
|
||||
} from "./values.js"
|
||||
|
||||
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
|
||||
export type Services<T> = ServicesOf<T, []>
|
||||
@@ -70,7 +69,6 @@ export type ToolDescription = {
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const defaultCatalogBudget = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
@@ -90,7 +88,7 @@ const SearchOutput = Schema.Struct({
|
||||
remaining: NonNegativeInt,
|
||||
next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
|
||||
})
|
||||
const toolExpression = (path: string) =>
|
||||
export const toolExpression = (path: string) =>
|
||||
"tools" +
|
||||
path
|
||||
.split(".")
|
||||
@@ -339,7 +337,6 @@ const visibleTools = <R>(tools: Tools<R>) =>
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly instructions: string
|
||||
readonly searchIndex: ReadonlyArray<SearchEntry>
|
||||
}
|
||||
|
||||
@@ -423,17 +420,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
}),
|
||||
})
|
||||
|
||||
const searchSignature = (() => {
|
||||
/** Exact callable signature of the built-in `search` function, for host-owned instructions. */
|
||||
export const searchSignature = (() => {
|
||||
const tool = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
||||
})()
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
const line = tool.description.split("\n", 1)[0]!.trim()
|
||||
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
|
||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||
}
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
@@ -451,146 +443,10 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
|
||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
|
||||
|
||||
// Budget signatures round-robin so every namespace remains visible.
|
||||
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||
}
|
||||
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
|
||||
const visible = visibleTools(tools)
|
||||
const described = visible.map(({ description }) => description)
|
||||
|
||||
const namespaces = new Map<string, Array<ToolDescription>>()
|
||||
for (const tool of described) {
|
||||
const [namespace = tool.path] = tool.path.split(".")
|
||||
const group = namespaces.get(namespace) ?? []
|
||||
group.push(tool)
|
||||
namespaces.set(namespace, group)
|
||||
}
|
||||
const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right))
|
||||
|
||||
const selections = ordered.map(([namespace, group]) => ({
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
queue: [...group].sort(
|
||||
(left, right) =>
|
||||
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path),
|
||||
),
|
||||
}))
|
||||
let used = 0
|
||||
let active = selections.filter((selection) => selection.queue.length > 0)
|
||||
while (active.length > 0) {
|
||||
const stillActive: typeof active = []
|
||||
for (const selection of active) {
|
||||
const tool = selection.queue[0]!
|
||||
const cost = estimateTokens(catalogLine(tool))
|
||||
if (used + cost > catalogBudget) continue
|
||||
selection.queue.shift()
|
||||
selection.picked.add(tool)
|
||||
used += cost
|
||||
if (selection.queue.length > 0) stillActive.push(selection)
|
||||
}
|
||||
active = stillActive
|
||||
}
|
||||
const shown = new Map<string, ReadonlySet<ToolDescription>>(
|
||||
selections.map(({ namespace, picked }) => [namespace, picked]),
|
||||
)
|
||||
const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
|
||||
const complete = totalShown === described.length
|
||||
|
||||
const empty = described.length === 0
|
||||
|
||||
const intro = [
|
||||
empty
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||
: complete
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available."
|
||||
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.",
|
||||
...(empty
|
||||
? []
|
||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
]
|
||||
|
||||
const workflow = empty
|
||||
? []
|
||||
: [
|
||||
"",
|
||||
"## Workflow",
|
||||
"",
|
||||
...(complete
|
||||
? [
|
||||
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
|
||||
"2. Call it using the exact signature shown: `const result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
|
||||
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
|
||||
]
|
||||
: [
|
||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
||||
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
|
||||
]),
|
||||
]
|
||||
|
||||
const rules = empty
|
||||
? []
|
||||
: [
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
complete
|
||||
? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed."
|
||||
: "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
|
||||
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
|
||||
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
|
||||
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
|
||||
"- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.",
|
||||
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
|
||||
...(complete
|
||||
? []
|
||||
: [
|
||||
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
|
||||
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
|
||||
]),
|
||||
]
|
||||
|
||||
const language = [
|
||||
"",
|
||||
"## Language",
|
||||
"",
|
||||
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
|
||||
"Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
|
||||
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
||||
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||
]
|
||||
|
||||
const toolSection: Array<string> = [""]
|
||||
if (empty) {
|
||||
toolSection.push("## Available tools", "", "No tools are currently available.")
|
||||
} else {
|
||||
toolSection.push(
|
||||
complete
|
||||
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
|
||||
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`,
|
||||
"",
|
||||
)
|
||||
for (const [namespace, group] of ordered) {
|
||||
const picked = shown.get(namespace)!
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
const label =
|
||||
picked.size === group.length
|
||||
? count
|
||||
: picked.size === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${picked.size} shown`
|
||||
toolSection.push(`- ${namespace} (${label})`)
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
if (!complete) {
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
|
||||
return {
|
||||
catalog: described,
|
||||
instructions: lines.join("\n"),
|
||||
catalog: visible.map(({ description }) => description),
|
||||
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
|
||||
}
|
||||
}
|
||||
@@ -612,7 +468,7 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
|
||||
const node = lookup(root, segments)
|
||||
if (node === undefined) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
|
||||
"Use search({ query }) to find available described tools.",
|
||||
"The tool may have been removed or renamed. Use search to find available tools.",
|
||||
])
|
||||
}
|
||||
if (node.tool === undefined) {
|
||||
@@ -685,7 +541,11 @@ export const make = <R>(
|
||||
const input = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
`Invalid input for tool '${name}': ${String(cause)}`,
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
})
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
return yield* observeEnd(
|
||||
|
||||
@@ -592,7 +592,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
|
||||
})
|
||||
|
||||
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
|
||||
test("describes the catalog and keeps the search built-in registered", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
@@ -601,16 +601,7 @@ describe("CodeMode public contract", () => {
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
|
||||
},
|
||||
])
|
||||
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool)")
|
||||
expect(runtime.instructions()).toContain(
|
||||
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
|
||||
)
|
||||
// A fully inlined catalog does not advertise search in the instructions...
|
||||
expect(runtime.instructions()).not.toContain("search(")
|
||||
|
||||
// ...but the search built-in stays available, so a speculative call still works with the
|
||||
// same signature as the inline catalog.
|
||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
@@ -646,22 +637,7 @@ describe("CodeMode public contract", () => {
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.instructions()).toBe(second.instructions())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
|
||||
for (const catalogBudget of [0, 10, 20, 40]) {
|
||||
expect(
|
||||
CodeMode.make({
|
||||
tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } },
|
||||
discovery: { catalogBudget },
|
||||
}).instructions(),
|
||||
).toBe(
|
||||
CodeMode.make({
|
||||
tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } },
|
||||
discovery: { catalogBudget },
|
||||
}).instructions(),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
@@ -680,9 +656,6 @@ describe("CodeMode public contract", () => {
|
||||
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
||||
},
|
||||
])
|
||||
expect(runtime.instructions()).toContain(
|
||||
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
||||
)
|
||||
|
||||
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
|
||||
expect(search.ok).toBe(true)
|
||||
@@ -713,88 +686,6 @@ describe("CodeMode public contract", () => {
|
||||
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
|
||||
})
|
||||
|
||||
test("instructions use markdown sections with placeholder-only call forms", () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
const instructions = runtime.instructions()
|
||||
// Sections in order: workflow at the top, catalog at the bottom.
|
||||
expect(instructions).toContain("## Workflow")
|
||||
expect(instructions).toContain("## Rules")
|
||||
expect(instructions).toContain("## Language")
|
||||
expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules"))
|
||||
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language"))
|
||||
expect(instructions.indexOf("## Language")).toBeLessThan(
|
||||
instructions.indexOf("\n## Available tools (COMPLETE list"),
|
||||
)
|
||||
expect(instructions).not.toContain("JSON.parse(res)")
|
||||
expect(instructions).toContain("Return only the fields you need")
|
||||
expect(instructions).toContain("avoid returning large raw payloads")
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).toContain("surrounding agent tools are not available")
|
||||
expect(instructions).toContain("Only tools listed here are available")
|
||||
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
|
||||
// and no real catalog tools cherry-picked into example lines.
|
||||
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
|
||||
expect(instructions).toContain("Return only the fields you need from structured results")
|
||||
expect(instructions).toContain("check that it is a non-null object and not an array")
|
||||
expect(instructions).not.toContain("result.<field>")
|
||||
expect(instructions).not.toContain("data.<field>")
|
||||
expect(instructions).not.toContain("total_count")
|
||||
expect(instructions).not.toContain("list_issues")
|
||||
expect(instructions).not.toContain("tools.orders.lookup({")
|
||||
// COMPLETE: step 1 picks from the inlined list; search is not advertised.
|
||||
expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`")
|
||||
expect(instructions).not.toContain("Browse one namespace")
|
||||
|
||||
const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions()
|
||||
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
|
||||
// a query string, never a tool name) and the browse-namespace rule appears.
|
||||
expect(partial).toContain(
|
||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
||||
)
|
||||
expect(partial).toContain("In the next execution, copy a returned path exactly")
|
||||
expect(partial).toContain("Only tools listed here or returned by the built-in `search` function")
|
||||
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
|
||||
expect(partial).toContain("repeat the same search with `offset: next.offset`")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).not.toContain("total_count")
|
||||
expect(partial).not.toContain("tools.orders.lookup({")
|
||||
})
|
||||
|
||||
test("the language section describes the restricted runtime without overclaiming", () => {
|
||||
const instructions = CodeMode.make({ tools }).instructions()
|
||||
expect(instructions).toContain("restricted JavaScript language for calling tools")
|
||||
expect(instructions).toContain("not a general-purpose runtime")
|
||||
expect(instructions).not.toContain("Standard modern JavaScript works")
|
||||
expect(instructions).not.toContain("TypeScript type annotations")
|
||||
for (const missing of ["Modules/imports", "classes", "fetch"]) {
|
||||
expect(instructions).toContain(missing)
|
||||
}
|
||||
expect(instructions).not.toContain("generators")
|
||||
expect(instructions).not.toContain("new Promise(...) are unavailable")
|
||||
expect(instructions).not.toContain("promise chaining")
|
||||
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
|
||||
expect(instructions).not.toContain("host globals")
|
||||
expect(instructions).toContain("Use tools for external operations")
|
||||
expect(instructions).toContain(
|
||||
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
||||
)
|
||||
expect(instructions).toContain(
|
||||
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||
)
|
||||
})
|
||||
|
||||
test("zero tools keep minimal sections and the no-tools notice", () => {
|
||||
const runtime = CodeMode.make({})
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain("No tools are currently available.")
|
||||
expect(instructions).toContain("## Language")
|
||||
expect(instructions).toContain("## Available tools")
|
||||
expect(instructions).not.toContain("## Workflow")
|
||||
expect(instructions).not.toContain("## Rules")
|
||||
expect(instructions).not.toContain("search(")
|
||||
})
|
||||
|
||||
test("uses one ranked search returning complete tools for large catalogs", async () => {
|
||||
const upload = Tool.make({
|
||||
description: "Upload one readable local file to the current Discord thread",
|
||||
@@ -810,13 +701,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
discovery: { catalogBudget: 0 },
|
||||
})
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
|
||||
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
|
||||
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
|
||||
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
@@ -1101,64 +986,6 @@ describe("CodeMode public contract", () => {
|
||||
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
|
||||
})
|
||||
|
||||
test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => {
|
||||
const cheap = Tool.make({
|
||||
description: "Cheap",
|
||||
input: Schema.Struct({ q: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const expensive = Tool.make({
|
||||
description:
|
||||
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
|
||||
input: Schema.Struct({
|
||||
someRatherLongParameterName: Schema.String,
|
||||
anotherEvenLongerParameterName: Schema.Number,
|
||||
}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
|
||||
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
|
||||
// other namespaces from inlining (beta already got its line in the same round).
|
||||
const runtime = CodeMode.make({
|
||||
tools: { alpha: { cheap, expensive }, beta: { cheap } },
|
||||
discovery: { catalogBudget: 40 },
|
||||
})
|
||||
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
// Fully shown namespaces read cleanly (no "shown" annotation).
|
||||
expect(instructions).toContain("- beta (1 tool)")
|
||||
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
|
||||
})
|
||||
|
||||
test("charges inline JSDoc against the catalog token budget", () => {
|
||||
const documented = Tool.make({
|
||||
description: "Look up a record",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "A detailed identifier description. ".repeat(20) },
|
||||
},
|
||||
required: ["id"],
|
||||
} as const,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { records: { lookup: documented } },
|
||||
discovery: { catalogBudget: 40 },
|
||||
})
|
||||
|
||||
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
|
||||
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
|
||||
})
|
||||
|
||||
test("decodes tool input and output before exposing either side", async () => {
|
||||
const observed: Array<unknown> = []
|
||||
const transformed = Tool.make({
|
||||
@@ -1219,7 +1046,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
|
||||
})
|
||||
|
||||
test("rejects invalid configuration and discovery limits", async () => {
|
||||
test("rejects invalid configuration and search limits", async () => {
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
|
||||
RangeError,
|
||||
@@ -1227,13 +1054,8 @@ describe("CodeMode public contract", () => {
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
|
||||
|
||||
expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools,
|
||||
discovery: { catalogBudget: 0 },
|
||||
}).execute(`return search({ query: "order", limit: 0.5 })`),
|
||||
CodeMode.make({ tools }).execute(`return search({ query: "order", limit: 0.5 })`),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
|
||||
@@ -395,15 +395,15 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("the inline catalog uses the same JSDoc signatures", async () => {
|
||||
const instructions = runtime.instructions()
|
||||
test("the catalog uses the same JSDoc signatures as search", async () => {
|
||||
const catalog = runtime.catalog()
|
||||
const github = (await search("list issues repository")).items.find(
|
||||
({ path }) => path === "tools.github.list_issues",
|
||||
)!
|
||||
const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")!
|
||||
expect(instructions).toContain(` - ${github.signature} // List issues in a repository`)
|
||||
expect(instructions).toContain(` - ${orders.signature} // Look up an order`)
|
||||
expect(instructions).toContain("/** Repository owner */")
|
||||
expect(catalog.map(({ signature }) => signature)).toContain(github.signature)
|
||||
expect(catalog.map(({ signature }) => signature)).toContain(orders.signature)
|
||||
expect(github.signature).toContain("/** Repository owner */")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -423,16 +423,10 @@ describe("non-identifier tool paths", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
test("inline catalog uses bracket notation for dashed tool names", () => {
|
||||
const instructions = runtime.instructions()
|
||||
|
||||
expect(instructions).toContain(
|
||||
test("catalog signatures use bracket notation for dashed tool names", () => {
|
||||
expect(runtime.catalog()[0]?.signature).toBe(
|
||||
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
|
||||
)
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).not.toContain("tools.context7.resolve-library-id")
|
||||
expect(instructions).not.toContain("tools.context7.resolve_library_id")
|
||||
})
|
||||
|
||||
test("search results return callable bracket-notation paths and signatures", async () => {
|
||||
|
||||
@@ -30,7 +30,6 @@ describe("dotted tool names", () => {
|
||||
expect(catalog).toHaveLength(1)
|
||||
expect(catalog[0]?.path).toBe("api.issues.list")
|
||||
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
|
||||
expect(runtime.instructions()).toContain("tools.api.issues.list(input:")
|
||||
})
|
||||
|
||||
test("the advertised dotted path is executable", async () => {
|
||||
@@ -86,6 +85,9 @@ describe("callable namespaces", () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
|
||||
expect(diagnostic.kind).toBe("UnknownTool")
|
||||
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
|
||||
expect(diagnostic.suggestions).toEqual([
|
||||
"The tool may have been removed or renamed. Use search to find available tools.",
|
||||
])
|
||||
})
|
||||
|
||||
test("a namespace without its own tool stays non-callable", async () => {
|
||||
@@ -96,6 +98,31 @@ describe("callable namespaces", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool input diagnostics", () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
"notes.echo": Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ text }) => Effect.succeed(text),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
test("a schema mismatch suggests searching for the current signature", async () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.notes.echo({ message: "hello" })`)
|
||||
expect(diagnostic.kind).toBe("InvalidToolInput")
|
||||
expect(diagnostic.suggestions).toEqual(["The signature may have changed. Use search to get the current signature."])
|
||||
})
|
||||
|
||||
test("a wrong argument count keeps the existing error without a stale-signature hint", async () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.notes.echo()`)
|
||||
expect(diagnostic.kind).toBe("InvalidToolInput")
|
||||
expect(diagnostic.suggestions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("blocked member names on tool paths", () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
|
||||
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
@@ -242,23 +242,22 @@ 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)
|
||||
return pipe(
|
||||
const selected = Array.min(
|
||||
items,
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => projectModel(item.model, provider)),
|
||||
Array.head,
|
||||
Order.mapInput(
|
||||
Order.Number,
|
||||
(item: (typeof candidates)[number]) =>
|
||||
(item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
|
||||
),
|
||||
)
|
||||
return projectModel(selected.model, provider)
|
||||
}
|
||||
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
),
|
||||
)
|
||||
const small = candidates.filter((item) => item.small)
|
||||
return pick(small.length > 0 ? small : candidates)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as CodeMode from "./codemode"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { CodeModeCatalog } from "./codemode/catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
@@ -9,7 +10,7 @@ import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: Any
|
||||
readonly instructions?: string
|
||||
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -67,7 +68,7 @@ const layer = Layer.effect(
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
tool: ExecuteTool.create(registrations),
|
||||
instructions: ExecuteTool.instructions(registrations),
|
||||
catalog: ExecuteTool.catalog(registrations),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
export * as CodeModeCatalog from "./catalog"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
const Listing = Schema.Struct({
|
||||
path: Schema.String,
|
||||
line: Schema.String,
|
||||
})
|
||||
|
||||
const Namespace = Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Number,
|
||||
entries: Schema.Array(Listing),
|
||||
})
|
||||
|
||||
export const Summary = Schema.Struct({
|
||||
total: Schema.Number,
|
||||
shown: Schema.Number,
|
||||
namespaces: Schema.Array(Namespace),
|
||||
})
|
||||
export type Summary = typeof Summary.Type
|
||||
|
||||
const DESCRIPTION_LIMIT = 120
|
||||
const CHARACTERS_PER_TOKEN = 4
|
||||
const INLINE_BUDGET = 2_000
|
||||
|
||||
// Keep every namespace searchable, then select full listings one per namespace per round,
|
||||
// considering shorter listings first until the inline budget is exhausted.
|
||||
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
|
||||
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
|
||||
.sort(([left], [right]) => {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
})
|
||||
.map(([name, namespaceEntries]) => {
|
||||
const listings = namespaceEntries
|
||||
.map((entry) => {
|
||||
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
|
||||
const description =
|
||||
firstLine.length > DESCRIPTION_LIMIT
|
||||
? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
|
||||
: firstLine
|
||||
const suffix = description.length === 0 ? "" : ` // ${description}`
|
||||
return { path: entry.path, line: ` - ${entry.signature}${suffix}` }
|
||||
})
|
||||
.toSorted((left, right) => {
|
||||
if (left.path < right.path) return -1
|
||||
if (left.path > right.path) return 1
|
||||
return 0
|
||||
})
|
||||
return {
|
||||
name,
|
||||
listings,
|
||||
selectionOrder: rankListings(listings),
|
||||
selectedListings: new Set<typeof Listing.Type>(),
|
||||
}
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
let remaining = budget
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
|
||||
if (!candidate || candidate.cost > remaining) {
|
||||
active.delete(namespace)
|
||||
continue
|
||||
}
|
||||
namespace.selectedListings.add(candidate.listing)
|
||||
remaining -= candidate.cost
|
||||
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
const namespaceSummaries = namespaces.map((namespace) => ({
|
||||
name: namespace.name,
|
||||
count: namespace.listings.length,
|
||||
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
|
||||
}))
|
||||
return {
|
||||
total: entries.length,
|
||||
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
|
||||
namespaces: namespaceSummaries,
|
||||
}
|
||||
}
|
||||
|
||||
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
|
||||
return listings
|
||||
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
|
||||
.toSorted((left, right) => {
|
||||
if (left.cost !== right.cost) return left.cost - right.cost
|
||||
if (left.listing.path < right.listing.path) return -1
|
||||
if (left.listing.path > right.listing.path) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
@@ -1,24 +1,141 @@
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(Schema.String)
|
||||
const render = {
|
||||
initial: (current: string) => current,
|
||||
changed: (_previous: string, current: string) =>
|
||||
[
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
current,
|
||||
].join("\n\n"),
|
||||
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
// 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\`.
|
||||
|
||||
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\`.
|
||||
|
||||
Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.<namespace>["tool-name"](input)\`.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
|
||||
- ${searchSignature}` : ""}
|
||||
|
||||
## Available tools`
|
||||
|
||||
export function render(catalog: CodeModeCatalog.Summary) {
|
||||
if (catalog.total === 0) return "No tools are currently available."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
const label =
|
||||
namespace.entries.length === namespace.count
|
||||
? count
|
||||
: namespace.entries.length === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${namespace.entries.length} shown`
|
||||
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
|
||||
})
|
||||
|
||||
return `${prompt(catalog.shown < catalog.total)}
|
||||
|
||||
${tools.join("\n")}`
|
||||
}
|
||||
|
||||
export const make = (content?: string): Instructions.Instructions =>
|
||||
Instructions.make({
|
||||
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
|
||||
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
|
||||
${render(current)}`
|
||||
const previousComplete = previous.shown === previous.total
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return full
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
current.namespaces.flatMap((namespace) => namespace.entries),
|
||||
(entry) => entry.path,
|
||||
(before, after) => before.line !== after.line,
|
||||
)
|
||||
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
|
||||
|
||||
if (!currentComplete) {
|
||||
if (entriesChanged) return full
|
||||
const namespaces = Instructions.diffByKey(
|
||||
previous.namespaces,
|
||||
current.namespaces,
|
||||
(namespace) => namespace.name,
|
||||
(before, after) => before.count !== after.count,
|
||||
)
|
||||
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
|
||||
if (!changed) return full
|
||||
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (namespaces.added.length > 0) {
|
||||
parts.push(
|
||||
`New tool namespaces are available: ${namespaces.added
|
||||
.map((namespace) => `\`${namespace.name}\` (${namespace.count} tools)`)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
if (namespaces.changed.length > 0) {
|
||||
parts.push(
|
||||
`The following namespace inventories changed; search them again before relying on previous results: ${namespaces.changed
|
||||
.map((change) => `\`${change.current.name}\` now has ${change.current.count} tools`)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
if (namespaces.removed.length > 0) {
|
||||
parts.push(
|
||||
`The following tool namespaces are no longer available and must not be used: ${namespaces.removed
|
||||
.map((namespace) => `\`${namespace.name}\``)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
}
|
||||
|
||||
if (!entriesChanged) return full
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (diff.added.length > 0) {
|
||||
parts.push(
|
||||
[
|
||||
"New tools are available in addition to those previously listed:",
|
||||
...diff.added.map((entry) => entry.line),
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
if (diff.changed.length > 0) {
|
||||
parts.push(
|
||||
[
|
||||
"Changed tool listings supersede the previously listed ones:",
|
||||
...diff.changed.map((change) => change.current.line),
|
||||
].join("\n"),
|
||||
)
|
||||
}
|
||||
if (diff.removed.length > 0) {
|
||||
parts.push(
|
||||
`The following tools are no longer available and must not be called: ${diff.removed
|
||||
.map((entry) => toolExpression(entry.path))
|
||||
.join(", ")}.`,
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
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(content ?? Instructions.removed),
|
||||
render,
|
||||
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.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ const layer = Layer.effect(
|
||||
agent: { ...agent, info: agent.info },
|
||||
instructions: Instructions.combine([
|
||||
loaded.builtins,
|
||||
CodeModeInstructions.make(loaded.toolSet.codeModeInstructions),
|
||||
CodeModeInstructions.make(loaded.toolSet.codeModeCatalog),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
loaded.references,
|
||||
|
||||
@@ -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 } from "effect"
|
||||
import { Context, Effect, Layer, LogLevel } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { ModelV2 } from "../model"
|
||||
@@ -10,6 +10,7 @@ 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,6 +88,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.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
|
||||
@@ -134,6 +136,23 @@ 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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionPending from "./pending"
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { and, asc, eq, or } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import {
|
||||
Compaction,
|
||||
@@ -26,6 +26,13 @@ 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)
|
||||
@@ -355,16 +362,32 @@ 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,
|
||||
delivery: Delivery,
|
||||
scope: Scope,
|
||||
) {
|
||||
if (yield* compaction(db, sessionID)) return false
|
||||
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
|
||||
const row = yield* db
|
||||
.select({ id: SessionPendingTable.id })
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, delivery)))
|
||||
.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),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -393,66 +416,74 @@ 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
|
||||
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
|
||||
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))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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 perform one physical attempt even when no work is eligible. */
|
||||
/** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
|
||||
readonly drain: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
@@ -28,6 +27,41 @@ 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* () {
|
||||
@@ -43,69 +77,126 @@ 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 titleAttempted = new Set<SessionSchema.ID>()
|
||||
const titleStarted = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
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
|
||||
/**
|
||||
* 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 failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
})
|
||||
|
||||
// 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* (
|
||||
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
|
||||
const runStep = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionPending.Delivery | undefined,
|
||||
promotable: SessionPending.Promotable,
|
||||
step: number,
|
||||
recoverOverflow?: typeof compaction.compact,
|
||||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
// 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()
|
||||
}
|
||||
// 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* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionPending.Promotable | undefined,
|
||||
step: number,
|
||||
recoverOverflow: boolean,
|
||||
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)
|
||||
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 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
|
||||
const loaded = yield* context.load(selected)
|
||||
const session = loaded.session
|
||||
const agent = loaded.agent
|
||||
const { session, agent } = loaded
|
||||
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 { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
if (compacted.status === "completed")
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||
return yield* new StepFailedError({ error: compacted.error })
|
||||
}
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
@@ -131,6 +222,40 @@ const layer = Layer.effect(
|
||||
// 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.
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
@@ -172,39 +297,10 @@ const layer = Layer.effect(
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
|
||||
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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Settle: only the stream itself is interruptible (restore); every line after it is
|
||||
// protected so a started call always reaches one durable outcome.
|
||||
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
|
||||
@@ -217,10 +313,9 @@ const layer = Layer.effect(
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
|
||||
.status === "completed"
|
||||
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
||||
|
||||
// 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
|
||||
@@ -230,9 +325,11 @@ 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,
|
||||
})
|
||||
@@ -248,30 +345,17 @@ const layer = Layer.effect(
|
||||
const settled = yield* restore(
|
||||
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
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 (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
||||
const tools = classifyToolExits(settled)
|
||||
|
||||
if (tools.declined || streamInterrupted || tools.interrupted) {
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
}
|
||||
// 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)
|
||||
if (tools.failure !== undefined) {
|
||||
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
|
||||
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||
@@ -319,68 +403,17 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.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 (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 (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation,
|
||||
step: currentStep,
|
||||
} as const
|
||||
return CallOutcome.Completed({ needsContinuation, step: currentStep })
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
@@ -399,69 +432,53 @@ const layer = Layer.effect(
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(compacted)) return
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// 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")
|
||||
/** 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,
|
||||
})
|
||||
}
|
||||
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 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ 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> =>
|
||||
@@ -50,7 +50,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
@@ -64,8 +64,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID ??= SessionMessage.ID.create()
|
||||
if (stepStarted) return assistantMessageID
|
||||
stepStarted = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -77,9 +76,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return assistantMessageID
|
||||
})
|
||||
const currentAssistantMessageID = () =>
|
||||
assistantMessageID === undefined
|
||||
? Effect.die(new Error("Tool event before assistant step start"))
|
||||
: Effect.succeed(assistantMessageID)
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||
const fragments = (
|
||||
name: string,
|
||||
|
||||
@@ -7,11 +7,9 @@ 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
|
||||
}> {}
|
||||
@@ -43,24 +41,20 @@ const retryAfter = (failure: RetryableFailure) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
|
||||
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID, assistantMessageID: () => SessionMessage.ID) =>
|
||||
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
|
||||
Schedule.passthrough,
|
||||
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||
Schedule.setInputType<RetryableFailure>(),
|
||||
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
|
||||
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
|
||||
const minimum = retryAfter(failure)
|
||||
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
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,
|
||||
events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: assistantMessageID(),
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -126,8 +126,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
|
||||
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
}
|
||||
|
||||
function runtime(
|
||||
|
||||
@@ -25,11 +25,16 @@ export const Input = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Entry)
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
type EncodedOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search results into the concise line-oriented output models expect. */
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
|
||||
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.)`,
|
||||
)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -74,11 +79,12 @@ export const Plugin = {
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
return yield* ripgrep
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
@@ -90,13 +96,15 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
)
|
||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
Effect.map((result) => ({
|
||||
output: result.entries,
|
||||
content: toModelContent(
|
||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { count: output.length },
|
||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -44,13 +45,13 @@ export interface Interface {
|
||||
}
|
||||
|
||||
/**
|
||||
* One request-scoped snapshot pairing Code Mode instructions and advertised
|
||||
* 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.
|
||||
*/
|
||||
export interface ToolSet {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeInstructions?: string
|
||||
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
|
||||
}
|
||||
|
||||
@@ -324,9 +325,9 @@ const registryLayer = Layer.effect(
|
||||
const codeModeMaterialization = yield* codeMode.materialize(permissions)
|
||||
const codemodeTool = codeModeMaterialization.tool
|
||||
return {
|
||||
...(codeModeMaterialization.instructions === undefined
|
||||
...(codeModeMaterialization.catalog === undefined
|
||||
? {}
|
||||
: { codeModeInstructions: codeModeMaterialization.instructions }),
|
||||
: { codeModeCatalog: codeModeMaterialization.catalog }),
|
||||
definitions: [
|
||||
// Definitions are prompt-cache prefix bytes, so order only after effective registrations settle.
|
||||
...Array.from(direct)
|
||||
|
||||
@@ -22,8 +22,13 @@ describe("CodeMode", () => {
|
||||
|
||||
const materialized = yield* codeMode.materialize()
|
||||
expect(materialized.tool).toBeDefined()
|
||||
expect(materialized.instructions).toContain("Echo text")
|
||||
expect(materialized.instructions).toContain("tools.echo(input:")
|
||||
expect(materialized.catalog).toStrictEqual([
|
||||
{
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
|
||||
path,
|
||||
description,
|
||||
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
|
||||
})
|
||||
|
||||
const lookup = entry(
|
||||
"orders.lookup",
|
||||
"Look up an order by ID",
|
||||
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
|
||||
)
|
||||
|
||||
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
|
||||
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
|
||||
|
||||
const update = (
|
||||
previous: ReadonlyArray<CodeModeCatalog.Entry>,
|
||||
current: ReadonlyArray<CodeModeCatalog.Entry>,
|
||||
budget?: number,
|
||||
) =>
|
||||
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
|
||||
|
||||
describe("CodeModeCatalog.summarize", () => {
|
||||
test("retains namespace inventory without retaining tools outside the inline budget", () => {
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
|
||||
0,
|
||||
)
|
||||
expect(catalog).toEqual({
|
||||
total: 10_000,
|
||||
shown: 0,
|
||||
namespaces: [{ name: "bulk", count: 10_000, entries: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("retains every namespace when no full tool listing fits", () => {
|
||||
const catalog = CodeModeCatalog.summarize(
|
||||
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
|
||||
0,
|
||||
)
|
||||
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
|
||||
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("retains only the rendered portion of inline descriptions", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
|
||||
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
|
||||
})
|
||||
|
||||
test("limits inline descriptions to 120 characters", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
|
||||
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
|
||||
expect(description).toHaveLength(120)
|
||||
expect(description).toEndWith("...")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeModeInstructions.render", () => {
|
||||
test("inlines complete catalogs without search guidance", () => {
|
||||
const instructions = render([lookup])
|
||||
expect(instructions).toContain("## Available tools")
|
||||
expect(instructions).toContain("- orders (1 tool)")
|
||||
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
|
||||
expect(instructions).not.toContain("## Search")
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain('`tools.<namespace>["tool-name"](input)`')
|
||||
})
|
||||
|
||||
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(
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
)
|
||||
expect(instructions).toContain("any calls still pending when execution ends are interrupted")
|
||||
expect(instructions).toContain("Run independent calls concurrently with `Promise.all`.")
|
||||
})
|
||||
|
||||
test("adds search guidance when the catalog exceeds the budget", () => {
|
||||
const partial = render([lookup], 0)
|
||||
expect(partial).toContain("## Available tools")
|
||||
expect(partial).toContain("- orders (1 tool, none shown)")
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("Only some tool signatures are shown.")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).toContain("or returned by `search`")
|
||||
expect(partial).not.toContain("tools.orders.lookup(input:")
|
||||
})
|
||||
|
||||
test("budgets signatures round-robin so every namespace remains visible", () => {
|
||||
const cheapAlpha = entry("alpha.cheap", "Cheap")
|
||||
const cheapBeta = entry("beta.cheap", "Cheap")
|
||||
const expensive = entry(
|
||||
"alpha.expensive",
|
||||
"Expensive",
|
||||
`tools.alpha.expensive(input: {\n aVeryLongParameterName: string,\n anotherEvenLongerParameterName: number,\n yetAnotherExtremelyVerboseParameterName: string,\n}): Promise<string>`,
|
||||
)
|
||||
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
|
||||
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
|
||||
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
|
||||
expect(instructions).toContain("## Search")
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
expect(instructions).toContain("- beta (1 tool)")
|
||||
expect(instructions).toContain(` - ${cheapBeta.signature} // Cheap`)
|
||||
})
|
||||
|
||||
test("charges inline JSDoc in signatures against the catalog token budget", () => {
|
||||
const documented = entry(
|
||||
"records.lookup",
|
||||
"Look up a record",
|
||||
`tools.records.lookup(input: {\n /** ${"A detailed identifier description. ".repeat(20).trim()} */\n id: string,\n}): Promise<string>`,
|
||||
)
|
||||
const instructions = render([documented], 40)
|
||||
expect(instructions).toContain("- records (1 tool, none shown)")
|
||||
expect(instructions).not.toContain("tools.records.lookup(input:")
|
||||
})
|
||||
|
||||
test("renders only the no-tools notice for an empty catalog", () => {
|
||||
expect(render([])).toBe("No tools are currently available.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeModeInstructions.update", () => {
|
||||
const echo = entry("notes.echo", "Echo text")
|
||||
|
||||
test("renders additions, changes, and removals as a compact semantic delta", () => {
|
||||
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
|
||||
const added = entry("notes.list", "List notes")
|
||||
const text = update([echo, lookup], [changed, added])
|
||||
expect(text).toContain("The Code Mode tool catalog has changed.")
|
||||
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
|
||||
expect(text).toContain(
|
||||
`Changed tool listings supersede the previously listed ones:\n - ${changed.signature} // Echo text`,
|
||||
)
|
||||
expect(text).toContain("The following tools are no longer available and must not be called: tools.orders.lookup.")
|
||||
expect(text).not.toContain("## Available tools")
|
||||
})
|
||||
|
||||
test("names removed tools with exact callable expressions including bracket notation", () => {
|
||||
const dashed = entry("context7.resolve-library-id", "Resolve a library ID")
|
||||
const text = update([echo, dashed], [echo])
|
||||
expect(text).toContain(
|
||||
'The following tools are no longer available and must not be called: tools.context7["resolve-library-id"].',
|
||||
)
|
||||
})
|
||||
|
||||
test("restates the full catalog when the rendering mode crosses full and compact", () => {
|
||||
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
|
||||
const text = update([echo], [echo, ...wide], 30)
|
||||
expect(text).toContain(
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
)
|
||||
expect(text).toContain("## Search")
|
||||
expect(text).toContain("## Available tools")
|
||||
})
|
||||
|
||||
test("falls back to full replacement when the delta is larger than the catalog", () => {
|
||||
const previous = Array.from({ length: 200 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
|
||||
const text = update([...previous, echo], [echo])
|
||||
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
|
||||
expect(text).toContain("## Available tools")
|
||||
expect(text).not.toContain("## Search")
|
||||
expect(text).not.toContain("must not be called")
|
||||
})
|
||||
|
||||
test("renders namespace-only deltas without persisting hidden tool entries", () => {
|
||||
const alpha = Array.from({ length: 10 }, (_, index) => entry(`alpha.tool${index}`, `Tool ${index}`))
|
||||
const text = update(alpha, [...alpha, entry("alpha.tool10", "Tool 10")], 0)
|
||||
expect(text).toContain("`alpha` now has 11 tools")
|
||||
expect(text).toContain("search them again before relying on previous results")
|
||||
expect(text).not.toContain("tools.alpha.tool10(input:")
|
||||
expect(text).not.toContain("## Available tools")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
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"
|
||||
@@ -7,8 +8,45 @@ import { Effect, Schema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
const echo: CodeModeCatalog.Entry = {
|
||||
path: "notes.echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
}
|
||||
|
||||
const lookup: CodeModeCatalog.Entry = {
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order",
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
|
||||
}
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("treats equivalent registration orders as an instruction no-op", () => {
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
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)
|
||||
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 })
|
||||
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({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
|
||||
const alpha = Tool.make({
|
||||
description: "Alpha tool",
|
||||
input: Schema.Struct({}),
|
||||
@@ -21,46 +59,25 @@ describe("CodeModeInstructions", () => {
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "zeta" }),
|
||||
})
|
||||
const codeModeLayer = AppNodeBuilder.build(CodeMode.node)
|
||||
const layer = AppNodeBuilder.build(CodeMode.node)
|
||||
|
||||
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" }))
|
||||
const materialization = yield* codeMode.materialize()
|
||||
return yield* readInitial(CodeModeInstructions.make(materialization.instructions))
|
||||
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" }))
|
||||
const materialization = yield* codeMode.materialize()
|
||||
return yield* readUpdate(CodeModeInstructions.make(materialization.instructions), initialized)
|
||||
return yield* readUpdate(CodeModeInstructions.make((yield* codeMode.materialize()).catalog), initialized)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(reordered.changed).toBe(false)
|
||||
expect(reordered.text).toBe("")
|
||||
}).pipe(Effect.provide(codeModeLayer))
|
||||
})
|
||||
|
||||
it.effect("renders catalog changes and removal", () => {
|
||||
let catalog: string | undefined = "Initial Code Mode catalog"
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make(catalog))
|
||||
expect(initialized.text).toBe("Initial Code Mode catalog")
|
||||
|
||||
catalog = "Updated Code Mode catalog"
|
||||
expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
|
||||
text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog",
|
||||
})
|
||||
|
||||
catalog = undefined
|
||||
expect(yield* readUpdate(CodeModeInstructions.make(catalog), initialized)).toMatchObject({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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" })
|
||||
})
|
||||
})
|
||||
@@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
|
||||
text: "First",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
|
||||
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.promoteSteers(db, events, parent.id)
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
yield* session.prompt({
|
||||
sessionID: forked.id,
|
||||
text: "Child continues",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promoteSteers(db, events, forked.id)
|
||||
yield* SessionPending.promote(db, events, forked.id, "steer")
|
||||
|
||||
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.promoteSteers(db, events, parent.id)
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
const second = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
text: "Second",
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionPending.promoteSteers(db, events, parent.id)
|
||||
yield* SessionPending.promote(db, events, parent.id, "steer")
|
||||
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.promoteSteers(db, events, created.id)
|
||||
yield* SessionPending.promote(db, events, created.id, "steer")
|
||||
|
||||
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.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||
yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
|
||||
@@ -59,7 +59,11 @@ 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.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
usage: { inputTokens: 100, outputTokens: 10 },
|
||||
}),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
@@ -97,7 +101,13 @@ const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(ToolRegistry.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
codeModeInstructions: "Captured Code Mode catalog",
|
||||
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")),
|
||||
}),
|
||||
@@ -293,7 +303,7 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
)
|
||||
expect(instructionUpdates).toHaveLength(1)
|
||||
expect(instructionUpdates?.[0]).toContain("Changed context")
|
||||
expect(instructionUpdates?.[0]).toContain("Captured Code Mode catalog")
|
||||
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
|
||||
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.promoteSteers(db, events, sessionID)
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
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.promoteSteers(db, events, sessionID)
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
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.promoteSteers(db, events, sessionID)
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
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.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)],
|
||||
[SessionPending.promote(db, events, sessionID, "steer"), SessionPending.promote(db, events, sessionID, "steer")],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
@@ -855,7 +855,7 @@ describe("SessionV2.prompt", () => {
|
||||
},
|
||||
})
|
||||
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
|
||||
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.promoteSteers(database.db, events, sessionID)
|
||||
yield* SessionPending.promote(database.db, events, sessionID, "steer")
|
||||
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 synthetic queue input pending until the queue boundary", () =>
|
||||
it.effect("keeps queued input pending until the idle boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -907,9 +907,15 @@ describe("SessionV2.prompt", () => {
|
||||
})
|
||||
|
||||
expect(input.delivery).toBe("queue")
|
||||
expect(yield* SessionPending.promoteSteers(db, events, sessionID)).toBe(0)
|
||||
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer"),
|
||||
).toBe(0)
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true)
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "input"),
|
||||
).toBe(1)
|
||||
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: input.id, type: "synthetic", text: "Queued completion" },
|
||||
])
|
||||
@@ -935,7 +941,7 @@ describe("SessionV2.prompt", () => {
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
yield* SessionPending.promote(db, events, sessionID, "steer")
|
||||
|
||||
expect(
|
||||
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
|
||||
@@ -978,10 +984,14 @@ describe("SessionV2.pending", () => {
|
||||
{ id: second.id, type: "user", delivery: "steer" },
|
||||
])
|
||||
|
||||
yield* SessionPending.promoteSteers(db, events, sessionID)
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "input"),
|
||||
).toBe(2)
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
|
||||
|
||||
yield* SessionPending.promoteNextQueued(db, events, sessionID)
|
||||
expect(
|
||||
yield* SessionPending.promote(db, events, sessionID, "input"),
|
||||
).toBe(1)
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -993,9 +1003,12 @@ 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,6 +45,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
},
|
||||
providerMetadataKey,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ describe("ToolRegistry", () => {
|
||||
.pipe(Scope.provide(scope))
|
||||
const toolSet = yield* service.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
expect(toolSet.codeModeInstructions).toContain("tools.echo")
|
||||
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)
|
||||
|
||||
@@ -844,12 +844,19 @@ describe("SessionRunnerLLM", () => {
|
||||
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 = [
|
||||
{ instructions: "Code Mode catalog A", tool: execute("A") },
|
||||
{ instructions: "Code Mode catalog B", tool: execute("B") },
|
||||
{ instructions: "Code Mode catalog C", tool: execute("C") },
|
||||
{ instructions: "Code Mode catalog D", tool: execute("D") },
|
||||
{ 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()]
|
||||
@@ -1036,9 +1043,7 @@ 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 },
|
||||
@@ -1067,9 +1072,7 @@ 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 },
|
||||
@@ -3099,7 +3102,7 @@ describe("SessionRunnerLLM", () => {
|
||||
streamFailure = undefined
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* Effect.yieldNow
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
|
||||
@@ -3111,7 +3114,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
yield* admit(session, "Recover interrupted tool")
|
||||
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -3168,7 +3171,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
yield* admit(session, "Recover interrupted hosted tool")
|
||||
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -3219,7 +3222,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
yield* admit(session, "Recover interrupted tool input")
|
||||
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -4220,7 +4223,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries a physical attempt without consuming the logical agent step", () =>
|
||||
it.effect("retries a model call without consuming the logical agent step", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
|
||||
@@ -86,13 +86,16 @@ 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 })
|
||||
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
|
||||
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)
|
||||
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(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("SubagentTool", () => {
|
||||
},
|
||||
})
|
||||
const database = yield* Database.Service
|
||||
yield* SessionPending.promoteSteers(database.db, events, parent.id)
|
||||
yield* SessionPending.promote(database.db, events, parent.id, "steer")
|
||||
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") return body
|
||||
if (body.type === "code" || body.type === "markdown") return textBody(body.content)
|
||||
if (body.type === "none" || body.type === "text" || body.type === "markdown") return body
|
||||
if (body.type === "code") return textBody(body.content)
|
||||
const snapshot = body.snapshot
|
||||
if (snapshot.kind === "code") return textBody(`${snapshot.title}\n${snapshot.content}`)
|
||||
if (snapshot.kind === "diff") {
|
||||
|
||||
@@ -767,23 +767,19 @@ export function RunSubagentSelectBody(props: {
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const [active, setActive] = createSignal(true)
|
||||
const entries = createMemo<SubagentEntry[]>(() =>
|
||||
props
|
||||
.tabs()
|
||||
.filter((item) => (active() ? item.status === "running" : item.status !== "running"))
|
||||
.map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
return {
|
||||
category: "",
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
}
|
||||
}),
|
||||
props.tabs().map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
return {
|
||||
category: "",
|
||||
display: title,
|
||||
description: title === item.label ? undefined : item.label,
|
||||
footer: subagentStatusLabel(item.status),
|
||||
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
|
||||
sessionID: item.sessionID,
|
||||
current: props.current() === item.sessionID,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
@@ -792,12 +788,6 @@ export function RunSubagentSelectBody(props: {
|
||||
onSelect: (item) => props.onSelect(item.sessionID),
|
||||
isCurrent: (item) => item.current,
|
||||
closeOnFirstUp: true,
|
||||
onKey(event) {
|
||||
if (event.name.toLowerCase() !== "tab") return false
|
||||
event.preventDefault()
|
||||
setActive((value) => !value)
|
||||
return true
|
||||
},
|
||||
onRows: props.onRows,
|
||||
})
|
||||
|
||||
@@ -811,7 +801,6 @@ export function RunSubagentSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint={`tab show ${active() ? "inactive" : "active"}`}
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
|
||||
@@ -104,7 +104,8 @@ type RunFooterOptions = {
|
||||
|
||||
export function resolveRunAgent(agents: RunAgent[], current: string | undefined) {
|
||||
const selectable = agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
return selectable.find((agent) => agent.id === current) ?? selectable.at(0)
|
||||
if (current === undefined) return selectable.at(0)
|
||||
return selectable.find((agent) => agent.id === current)
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
@@ -327,6 +328,7 @@ 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,10 +29,11 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunFormBody } from "./footer.form"
|
||||
import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { footerStatuslinePolicy } 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,
|
||||
@@ -79,6 +80,7 @@ type RunFooterViewProps = {
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentAgent: () => string
|
||||
currentAgentID: () => string | undefined
|
||||
currentAgentExplicit: () => boolean
|
||||
currentModel: () => RunInput["model"]
|
||||
variants: () => string[]
|
||||
currentVariant: () => string | undefined
|
||||
@@ -116,7 +118,6 @@ 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 (
|
||||
@@ -410,19 +411,19 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return shell() ? "Shell mode" : ""
|
||||
})
|
||||
const activityMeta = createMemo(() => {
|
||||
if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (!footerDetails()) 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() || !responsive().statusline.showModel || !current) return
|
||||
if (!footerDetails() || !prompt() || shell() || !current) return
|
||||
return {
|
||||
agent: props.currentAgent(),
|
||||
model: current,
|
||||
variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
|
||||
variant: props.currentVariant(),
|
||||
}
|
||||
})
|
||||
const statusColor = createMemo(() => {
|
||||
@@ -441,32 +442,26 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return theme().muted
|
||||
})
|
||||
const statuslineBackground = createMemo(() => theme().status)
|
||||
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
|
||||
const hasModelStatus = createMemo(() => Boolean(modelStatus()))
|
||||
const contextHints = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) {
|
||||
const contextHintCandidates = createMemo(() => {
|
||||
if (!footerDetails() || !prompt() || shell()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items: Array<{ kind: string; key: string; label: string }> = []
|
||||
const items: Array<{ key: string; label: string }> = []
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ kind: "queued", key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
|
||||
const limit = responsive().statusline.contextHintLimit
|
||||
return limit === undefined ? items : items.slice(0, limit)
|
||||
return items
|
||||
})
|
||||
const hasContextHints = createMemo(() => contextHints().length > 0)
|
||||
const commandHint = createMemo(() => {
|
||||
if (!prompt() || !responsive().statusline.showCommandHint) {
|
||||
return
|
||||
}
|
||||
if (!prompt()) return
|
||||
|
||||
if (shell()) {
|
||||
return { key: "esc", label: "normal" }
|
||||
@@ -476,6 +471,49 @@ 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(() => {
|
||||
@@ -876,7 +914,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
flexShrink={0}
|
||||
backgroundColor={statuslineBackground()}
|
||||
>
|
||||
<Show when={modeLabel()}>
|
||||
<Show when={visibleModeLabel()}>
|
||||
{(label) => (
|
||||
<box
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
@@ -896,12 +934,21 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={12}
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
paddingRight={1}
|
||||
minWidth={0}
|
||||
paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
|
||||
paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
|
||||
backgroundColor="transparent"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={footerDetails() && busy() && !exiting()}>
|
||||
<Show
|
||||
when={
|
||||
footerDetails() &&
|
||||
busy() &&
|
||||
!exiting() &&
|
||||
statuslineMainAvailable() >=
|
||||
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
|
||||
}
|
||||
>
|
||||
<box flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
@@ -917,29 +964,36 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
</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 when={statuslineLayout().showUsage && activityMeta()}>
|
||||
{(usage) => (
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().muted} wrapMode="none">
|
||||
{usage()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={modelStatus()}>
|
||||
<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()}>
|
||||
{(info) => (
|
||||
<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>
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
{info().model}
|
||||
<Show when={info().variant}>
|
||||
<Show when={statuslineLayout().showVariant && info().variant}>
|
||||
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
|
||||
</Show>
|
||||
</text>
|
||||
@@ -949,25 +1003,20 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
<For each={contextHints()}>
|
||||
{(hint, index) => (
|
||||
<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>
|
||||
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={index() > 0 || (hasStatuslineInfo() && 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 paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
|
||||
<text fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
|
||||
{sectionSeparator()}
|
||||
</Show>
|
||||
<box backgroundColor="transparent" flexShrink={0}>
|
||||
<text fg={theme().text} wrapMode="none">
|
||||
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
|
||||
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
|
||||
<span style={{ fg: theme().muted }}>{hint().label}</span>
|
||||
</text>
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
// 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: !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,
|
||||
narrow: width < 80,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
+122
-13
@@ -1,10 +1,17 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
Renderable,
|
||||
StyledText,
|
||||
TextRenderable,
|
||||
TextTableRenderable,
|
||||
isStyledText,
|
||||
stringToStyledText,
|
||||
type BorderCharacters,
|
||||
type CliRendererExternalOutputEvent,
|
||||
type MarkdownOptions,
|
||||
type Renderable,
|
||||
type TreeSitterClient,
|
||||
} from "@opentui/core"
|
||||
|
||||
const prefixes: Record<number, string> = {
|
||||
@@ -52,27 +59,129 @@ const asciiBorder: BorderCharacters = {
|
||||
cross: "+",
|
||||
}
|
||||
|
||||
const hooked = new WeakSet<Renderable>()
|
||||
|
||||
export const monoMarkdownTableOptions = {
|
||||
style: "columns" as const,
|
||||
widthMode: "content" as const,
|
||||
borders: false,
|
||||
}
|
||||
|
||||
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
|
||||
export function monoMarkdownRenderable(renderable: MarkdownRenderable): void {
|
||||
monoRenderable(renderable)
|
||||
}
|
||||
|
||||
function monoBorders(renderable: Renderable): void {
|
||||
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)
|
||||
}
|
||||
|
||||
if (renderable instanceof BoxRenderable) renderable.customBorderChars = asciiBorder
|
||||
renderable.getChildren().forEach(monoBorders)
|
||||
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)
|
||||
}
|
||||
|
||||
export function monoMarkdown(value: string, mono: boolean): string {
|
||||
if (!mono) return value
|
||||
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 {
|
||||
return value.replace(/[^\t\n\x20-\x7e]/gu, (char) => markdown[char.codePointAt(0)!] ?? "?")
|
||||
}
|
||||
|
||||
@@ -80,7 +189,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 { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
|
||||
import { monoMarkdownRenderable, 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 = monoMarkdown(active.content, this.mono)
|
||||
renderable.content = active.content
|
||||
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(false))
|
||||
this.markRendered(await this.finishActive(entryFlags(commit).trailingNewline))
|
||||
}
|
||||
this.tail = commit
|
||||
return
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { createScrollbackWriter } from "@opentui/solid"
|
||||
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
|
||||
import {
|
||||
MarkdownRenderable,
|
||||
TextRenderable,
|
||||
type ColorInput,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { Match, Switch, createMemo } from "solid-js"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
|
||||
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
@@ -237,13 +243,15 @@ 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={monoMarkdown(markdown()!.content, props.opts?.mono === true)}
|
||||
content={markdown()!.content}
|
||||
fg={color()}
|
||||
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
|
||||
renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -231,29 +231,28 @@ describe("run entry body", () => {
|
||||
})
|
||||
|
||||
test("promotes subagent results to markdown and falls back to structured summaries", () => {
|
||||
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({
|
||||
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 = {
|
||||
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,6 +49,7 @@ 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("resolves the first selectable agent when none is selected", () => {
|
||||
test("falls back only when no agent 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("resolves the first selectable agent when none is selected", () => {
|
||||
|
||||
expect(resolveRunAgent(agents, undefined)?.id).toBe("build")
|
||||
expect(resolveRunAgent(agents, "plan")?.id).toBe("plan")
|
||||
expect(resolveRunAgent(agents, "missing")?.id).toBe("build")
|
||||
expect(resolveRunAgent(agents, "missing")).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -157,6 +157,7 @@ async function renderFooter(
|
||||
providers={() => input.providers}
|
||||
currentAgent={() => input.currentAgent ?? "Build"}
|
||||
currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"}
|
||||
currentAgentExplicit={() => input.currentAgent !== undefined}
|
||||
currentModel={() => input.currentModel}
|
||||
variants={() => []}
|
||||
currentVariant={() => input.currentVariant}
|
||||
@@ -208,11 +209,13 @@ async function renderFooter(
|
||||
}
|
||||
}
|
||||
|
||||
test("direct footer shows the generic default model before resolution", async () => {
|
||||
test("direct footer shows the default model without the fallback agent", async () => {
|
||||
const app = await renderFooter({ state: { model: "Default model" } })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Default model")
|
||||
const frame = app.captureCharFrame()
|
||||
expect(frame).toContain("Default model")
|
||||
expect(frame).not.toContain("Build")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
@@ -378,6 +381,83 @@ test("run entry content updates when live commit text changes", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("run entry content preserves monochrome markdown grammar", async () => {
|
||||
const [commit, setCommit] = createSignal<StreamCommit>({
|
||||
kind: "assistant",
|
||||
text: "• literal\n\n———\n\narrow →",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={60} height={8}>
|
||||
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} opts={{ mono: true }} />
|
||||
</box>
|
||||
),
|
||||
{ width: 60, height: 8 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const rows = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((row) => row.trimEnd())
|
||||
expect(rows).toContain("* literal")
|
||||
expect(rows).toContain("------")
|
||||
expect(rows).toContain("arrow ->")
|
||||
expect(rows.join("\n")).not.toMatch(/[^\x00-\x7f]/)
|
||||
|
||||
setCommit({ ...commit(), text: "- Café\n- arrow →\n- third …" })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("- arrow ->")
|
||||
expect(app.captureCharFrame()).toContain("- third ...")
|
||||
|
||||
setCommit({ ...commit(), text: "| A | B |\n| - | - |\n| Café | → |" })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Caf?")
|
||||
expect(app.captureCharFrame()).toContain("->")
|
||||
setCommit({ ...commit(), text: "| A | B |\n| - | - |\n| Café | … |" })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("...")
|
||||
|
||||
setCommit({ ...commit(), text: "```\nCafé → …\n```" })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Caf? -> ...")
|
||||
expect(app.captureCharFrame()).not.toMatch(/[^\x00-\x7f]/)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("run entry content eagerly renders final monochrome markdown", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={60} height={6}>
|
||||
<RunEntryContent
|
||||
commit={{ kind: "tool", text: "", phase: "final", source: "tool", tool: "subagent" }}
|
||||
body={{ type: "markdown", content: "# Café →\n\n```markdown\nCafé →\n```" }}
|
||||
theme={RUN_THEME_FALLBACK}
|
||||
opts={{ mono: true }}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 60, height: 6 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
expect(frame).toContain("# Caf? ->")
|
||||
expect(frame).toContain("Caf? ->")
|
||||
expect(frame).not.toMatch(/[^\x00-\x7f]/)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct command panel renders grouped actions without catalog commands", async () => {
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([
|
||||
command({ name: "review", description: "Review code" }),
|
||||
@@ -740,7 +820,7 @@ test("direct command panel keeps completed subagents available", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("direct subagent panel toggles between active and inactive subagents", async () => {
|
||||
test("direct subagent panel renders active subagents", async () => {
|
||||
const [tabs] = createSignal([
|
||||
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
|
||||
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
|
||||
@@ -776,22 +856,12 @@ test("direct subagent panel toggles between active and inactive subagents", asyn
|
||||
|
||||
expect(frame).toContain("Select subagent")
|
||||
expect(frame).toContain("Inspect auth flow")
|
||||
expect(frame).not.toContain("Write migration plan")
|
||||
expect(frame).not.toContain("done")
|
||||
expect(frame).toContain("tab show inactive")
|
||||
expect(frame).toContain("Write migration plan")
|
||||
expect(frame).toContain("done")
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
expect(rows).toBe(7)
|
||||
|
||||
app.mockInput.pressKey("TAB")
|
||||
await app.renderOnce()
|
||||
const inactive = app.captureCharFrame()
|
||||
|
||||
expect(inactive).not.toContain("Inspect auth flow")
|
||||
expect(inactive).toContain("Write migration plan")
|
||||
expect(inactive).toContain("done")
|
||||
expect(inactive).toContain("tab show active")
|
||||
expect(rows).toBe(8)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -1189,6 +1259,7 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
providers={() => undefined}
|
||||
currentAgent={() => "Build"}
|
||||
currentAgentID={() => "build"}
|
||||
currentAgentExplicit={() => false}
|
||||
currentModel={() => ({
|
||||
providerID: "opencode",
|
||||
modelID: "a-model-name-long-enough-to-force-responsive-truncation",
|
||||
@@ -1286,14 +1357,13 @@ test("direct footer progressively adds model details after the command hint", as
|
||||
for (const expected of [
|
||||
{ width: 24, agent: false, model: false, variant: false },
|
||||
{ width: 32, agent: false, model: true, variant: false },
|
||||
{ width: 40, agent: false, model: true, variant: true },
|
||||
{ width: 80, agent: true, model: true, variant: true },
|
||||
{ width: 40, agent: true, model: true, variant: false },
|
||||
{ width: 48, agent: true, model: true, variant: true },
|
||||
]) {
|
||||
const app = await renderFooter({
|
||||
providers: [provider()],
|
||||
currentAgent: "Plan",
|
||||
currentModel: { providerID: "opencode", modelID: "gpt-5" },
|
||||
currentVariant: "xhigh",
|
||||
state: { model: "GPT-5" },
|
||||
width: expected.width,
|
||||
})
|
||||
|
||||
@@ -1313,6 +1383,66 @@ test("direct footer progressively adds model details after the command hint", as
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer keeps commands and active work ahead of usage under width pressure", async () => {
|
||||
const app = await renderFooter({
|
||||
currentAgent: "Plan",
|
||||
subagents: {
|
||||
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
|
||||
details: {},
|
||||
permissions: [],
|
||||
forms: [],
|
||||
},
|
||||
state: {
|
||||
phase: "running",
|
||||
model: "a-model-name-long-enough-to-force-responsive-truncation",
|
||||
usage: "159.6K (16%) · $4.23",
|
||||
},
|
||||
width: 80,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Plan")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).toContain("↓ subagents")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).not.toContain("a-model-name")
|
||||
expect(frame).not.toContain("159.6K")
|
||||
expect(frame).not.toContain("$4.23")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer keeps the command hint at its minimum width", async () => {
|
||||
const app = await renderFooter({ state: { phase: "running" }, width: 10 })
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("ctrl+p cmd")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer keeps complete status text ahead of the spinner", async () => {
|
||||
const app = await renderFooter({
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }),
|
||||
state: { phase: "running" },
|
||||
width: 22,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("interrupt")
|
||||
expect(boxPath(footerStatusline(app.renderer.root), "SpinnerRenderable")).toBeUndefined()
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer always offers backgrounding for a foreground subagent", async () => {
|
||||
const app = await renderFooter({
|
||||
subagents: {
|
||||
@@ -1392,6 +1522,27 @@ test("direct footer shows full usage metadata when room is available", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer omits usage when it would fill the statusline", async () => {
|
||||
const app = await renderFooter({
|
||||
state: { phase: "running", model: "GPT-5.6 SoL", usage: "8.4K (1%) · $0.01" },
|
||||
currentVariant: "high",
|
||||
mono: true,
|
||||
width: 66,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("esc interrupt")
|
||||
expect(frame).toContain("GPT-5.6 SoL high")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).not.toContain("8.4K")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer hides routine activity and shows explicit notices", async () => {
|
||||
let status = ""
|
||||
const app = await renderFooter({
|
||||
|
||||
@@ -2,29 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { footerWidthPolicy } from "../../src/mini/footer.width"
|
||||
|
||||
describe("run footer width", () => {
|
||||
test("preserves shared dialog and statusline breakpoints", () => {
|
||||
expect([23, 24].map((width) => footerWidthPolicy(width).statusline.showCommandHint)).toEqual([false, true])
|
||||
expect([31, 32].map((width) => footerWidthPolicy(width).statusline.showModel)).toEqual([false, true])
|
||||
expect([39, 40].map((width) => footerWidthPolicy(width).statusline.showModelVariant)).toEqual([false, true])
|
||||
|
||||
const narrow = footerWidthPolicy(79)
|
||||
expect(narrow.dialog.narrow).toBe(true)
|
||||
expect(narrow.statusline.showActivityMeta).toBe(false)
|
||||
expect(narrow.statusline.showAgent).toBe(false)
|
||||
expect(narrow.statusline.showContextHints).toBe(false)
|
||||
expect(narrow.statusline.contextHintLimit).toBe(0)
|
||||
|
||||
const compact = footerWidthPolicy(80)
|
||||
expect(compact.dialog.narrow).toBe(false)
|
||||
expect(compact.statusline.showActivityMeta).toBe(true)
|
||||
expect(compact.statusline.showAgent).toBe(true)
|
||||
expect(compact.statusline.showContextHints).toBe(true)
|
||||
expect(compact.statusline.contextHintLimit).toBe(1)
|
||||
|
||||
const context = footerWidthPolicy(120)
|
||||
expect(context.statusline.contextHintLimit).toBe(2)
|
||||
|
||||
const spacious = footerWidthPolicy(150)
|
||||
expect(spacious.statusline.contextHintLimit).toBeUndefined()
|
||||
test("preserves the dialog breakpoint", () => {
|
||||
expect(footerWidthPolicy(79).dialog.narrow).toBe(true)
|
||||
expect(footerWidthPolicy(80).dialog.narrow).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,7 @@ async function setup(
|
||||
theme?: RunTheme
|
||||
onThemeRelease?: (theme: RunTheme) => void
|
||||
mono?: boolean
|
||||
failHighlight?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const out = await createTestRenderer({
|
||||
@@ -83,6 +84,11 @@ async function setup(
|
||||
|
||||
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
|
||||
treeSitterClient.setMockResult({ highlights: [] })
|
||||
if (input.failHighlight) {
|
||||
treeSitterClient.highlightOnce = async () => {
|
||||
throw new Error("highlight failed")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
renderer: out.renderer,
|
||||
@@ -217,7 +223,25 @@ test("renders monochrome scrollback as ASCII markdown", async () => {
|
||||
try {
|
||||
await out.scrollback.append(assistant("# H"))
|
||||
expect(Reflect.get(out.scrollback, "active")?.renderable).toBeInstanceOf(MarkdownRenderable)
|
||||
await out.scrollback.append(assistant("éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |"))
|
||||
await out.scrollback.append(
|
||||
assistant(
|
||||
"éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |\n\n• literal\n\n———\n\n[café](https://example.com/café)",
|
||||
),
|
||||
)
|
||||
const active: unknown = Reflect.get(out.scrollback, "active")
|
||||
const renderable =
|
||||
active && typeof active === "object" && "renderable" in active && active.renderable instanceof MarkdownRenderable
|
||||
? active.renderable
|
||||
: undefined
|
||||
expect(renderable?._blockStates.slice(-3).map((state) => state.token.type)).toEqual([
|
||||
"paragraph",
|
||||
"paragraph",
|
||||
"paragraph",
|
||||
])
|
||||
const link = renderable?._blockStates.at(-1)?.token
|
||||
const tokens = link && "tokens" in link && Array.isArray(link.tokens) ? link.tokens : []
|
||||
const href = tokens.find((token) => "href" in token)
|
||||
expect(href && "href" in href ? href.href : undefined).toBe("https://example.com/café")
|
||||
await out.scrollback.complete()
|
||||
out.renderer.writeToScrollback((ctx) => ({
|
||||
root: new TextRenderable(ctx.renderContext, {
|
||||
@@ -235,6 +259,8 @@ test("renders monochrome scrollback as ASCII markdown", async () => {
|
||||
expect(rendered).toContain('| "quote"')
|
||||
expect(rendered).toContain("------------------------------------------------------------")
|
||||
expect(rendered).toContain("? ?")
|
||||
expect(rendered).toContain("* literal")
|
||||
expect(rendered).toContain("------")
|
||||
expect(rendered).toContain("plain ? emoji ?")
|
||||
expect(rendered).not.toMatch(/[^\x00-\x7f]/)
|
||||
} finally {
|
||||
@@ -243,6 +269,64 @@ test("renders monochrome scrollback as ASCII markdown", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("renders completed subagent markdown in monochrome mode", async () => {
|
||||
const out = await setup({ mono: true, width: 60 })
|
||||
|
||||
try {
|
||||
await out.scrollback.append(
|
||||
toolCommit({
|
||||
tool: "subagent",
|
||||
phase: "final",
|
||||
toolState: "completed",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: "Inspect reducer", agent: "explore" },
|
||||
content: [{ type: "text", text: "# Findings\n\n- Café → stable" }],
|
||||
metadata: {
|
||||
sessionID: "ses-child-1",
|
||||
status: "completed",
|
||||
output: "# Findings\n\n- Café → stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const commits = claim(out.renderer)
|
||||
try {
|
||||
expect(commits).toHaveLength(1)
|
||||
expect(commits[0]?.trailingNewline).toBe(true)
|
||||
const output = render(commits)
|
||||
expect(output).toContain("# Findings")
|
||||
expect(output).toContain("- Caf? -> stable")
|
||||
expect(output).not.toMatch(/[^\x00-\x7f]/)
|
||||
} finally {
|
||||
destroy(commits)
|
||||
}
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps fenced code monochrome when highlighting fails", async () => {
|
||||
const out = await setup({ mono: true, failHighlight: true })
|
||||
|
||||
try {
|
||||
await out.scrollback.append(assistant("```ts\nCafé → …\n```"))
|
||||
await out.scrollback.complete()
|
||||
|
||||
const commits = claim(out.renderer)
|
||||
try {
|
||||
const output = render(commits)
|
||||
expect(output).toContain("Caf? -> ...")
|
||||
expect(output).not.toMatch(/[^\x00-\x7f]/)
|
||||
} finally {
|
||||
destroy(commits)
|
||||
}
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function user(text: string): StreamCommit {
|
||||
return {
|
||||
kind: "user",
|
||||
|
||||
Reference in New Issue
Block a user