Compare commits

..

71 Commits

Author SHA1 Message Date
Kit Langton c34e505026 chore: remove updated screenshot asset 2026-07-21 20:52:58 +00:00
Kit Langton 16863ae97b docs: add updated desktop screenshot 2026-07-21 20:52:33 +00:00
Kit Langton 314ca785df fix(desktop): use requested default background 2026-07-21 20:50:56 +00:00
Kit Langton a2697b9b08 chore: remove temporary screenshot asset 2026-07-21 20:32:41 +00:00
Kit Langton 5de3d73f24 docs: add desktop background screenshot 2026-07-21 20:31:53 +00:00
Kit Langton 4bf7210512 feat(desktop): add default background 2026-07-21 20:29:33 +00:00
Brendan Allan 4e4729535e fix(app): pad the workspace bar 2026-07-21 00:04:58 +00:00
Brendan Allan 98bb5e3bb4 fix(app): center the workspace bar 2026-07-21 00:02:13 +00:00
Brendan Allan 8eec2c6d57 fix(app): glass the workspace bar 2026-07-20 23:59:10 +00:00
Brendan Allan 6fb632eeb6 fix(app): tune background presentation 2026-07-20 23:47:03 +00:00
Brendan Allan 5452a13e07 fix(app): refine background contrast 2026-07-20 23:43:41 +00:00
Brendan Allan d881c1c476 fix(app): make background prompt translucent 2026-07-20 16:53:40 +00:00
Brendan Allan 364e2d9f03 feat(app): support web image backgrounds 2026-07-20 16:19:59 +00:00
Brendan Allan 35fcf1ed58 feat(desktop): add image backgrounds 2026-07-20 16:10:39 +00:00
James Long e8b19afa8f fix(tui): adapt file badge accent to theme mode (#37953) 2026-07-20 11:45:29 -04:00
Aiden Cline 3da0dea8e7 feat(codemode): expand standard library parity (#37943) 2026-07-20 09:59:37 -05:00
Dax Raad c7aa47c144 feat(tui): implement session timeline 2026-07-20 09:53:07 -04:00
James Long 7be95bcd6a feat(tui): support single-mode themes (#37930) 2026-07-20 09:37:48 -04:00
opencode-agent[bot] 3f4fb3f9db fix(tui): show opencode2 continuation command (#37933)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-20 13:36:02 +00:00
James Long 6702ce0d3f fix(tui): soften theme scale extremes (#37555) 2026-07-20 09:20:20 -04:00
Simon Klee a9895a1e8a fix(tui/mini): idle footer when session idles (#37910) 2026-07-20 13:22:46 +02:00
Simon Klee 75c7ac6a2c tui/mini: consolidate stream and panel internals (#37903) 2026-07-20 12:45:48 +02:00
Simon Klee 0eb71d0fc7 refactor(tui): extract shared frontend helpers (#37868) 2026-07-20 10:43:02 +02:00
Aiden Cline deee40c572 fix(codemode): parse canonical index keys (#37851) 2026-07-19 23:55:53 -05:00
Aiden Cline 8b5655ed53 fix(ai): preserve compatible reasoning details (#37708) 2026-07-19 22:30:55 -05:00
opencode-agent[bot] b9525b5878 fix(ai): expand context overflow patterns (#37848)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:22:56 -05:00
opencode-agent[bot] 2b8d1998d6 fix(ai): buffer partial tool call identity (#37847)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:12:49 -05:00
Aiden Cline 391cfbcfe7 fix(codemode): align string, array, and Date behavior (#37775) 2026-07-19 21:41:06 -05:00
Dax Raad fe0c74f4df fix(cli): accept updated service on restart 2026-07-19 19:29:40 -04:00
Kit Langton 6d32bc9cb0 feat(simulation): control arbitrary tool lifecycles (#37816) 2026-07-19 17:40:30 -04:00
Simon Klee 925c2423de mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive
run paths. Recover from transport drops, replace questions with
forms, and keep tool/catalog state location-scoped with live
progress and theme discovery.
2026-07-19 22:45:10 +02:00
Kit Langton 71cb419570 fix(simulation): validate semantic click identity (#37808) 2026-07-19 16:43:53 -04:00
Kit Langton edc93ceff1 feat(simulation): expose semantic UI snapshots (#37802) 2026-07-19 16:04:27 -04:00
opencode-agent[bot] 86a468c4d8 fix(tui): move agent cycling to shift-tab (#37706)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-19 21:45:03 +02:00
opencode-agent[bot] f05d2ab551 fix(tui): load root sessions in switcher (#37784)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-19 16:32:32 +00:00
opencode-agent[bot] bef6cfbffe feat(client): support opaque payload schemas (#37773)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 16:08:26 +00:00
Aiden Cline fe9a936867 feat(server): expose runtime MCP controls over HTTP (#37712) 2026-07-19 10:28:25 -05:00
Aiden Cline d5669ca934 docs(codemode): streamline README (#37769) 2026-07-19 10:18:24 -05:00
opencode-agent[bot] 04f0a771a3 feat(ai): add OpenAI image generation (#37714)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 10:17:49 -05:00
Simon Klee c50554d907 mini: move frontend into tui package (#37754) 2026-07-19 14:12:22 +02:00
Simon Klee 3f5ad8441f cli: extract run from mini package (#37737) 2026-07-19 11:11:03 +02:00
Simon Klee cf6e5b3604 mini: fix shell tool output display (#37711) 2026-07-19 09:31:38 +02:00
Aiden Cline cbcf191fdb fix(core): detach disposed MCP registrations from root scope (#37660) 2026-07-18 23:38:07 -05:00
Kit Langton ba0bbdafaa fix(core): preserve the first terminal failure (#37705) 2026-07-19 00:00:23 -04:00
Kit Langton a288cb5a0c fix(core): continue after malformed tool input (#37701) 2026-07-18 23:09:08 -04:00
Kit Langton 57ff57595a fix(core): safely recover malformed tool input (#37698) 2026-07-18 21:52:45 -04:00
Kit Langton 584fdefe6f fix(simulation): render screenshot symbol glyphs (#37691) 2026-07-18 21:36:21 -04:00
Kit Langton c310ef82f4 fix(core): authorize relative external paths (#37689) 2026-07-18 19:55:18 -04:00
Dax Raad 4e85a37590 fix(tui): auto-approve permissions in auto mode 2026-07-18 17:54:59 -04:00
opencode-agent[bot] fe9b051d1a feat(core): allow MCP Code Mode opt-out (#37681)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-18 17:17:56 -04:00
Aiden Cline 33f1b269e9 fix(codemode): stop leaking undefined into tool arguments (#37652) 2026-07-18 13:42:16 -05:00
opencode-agent[bot] 0d68b0bb20 fix(tui): style interrupted compaction neutrally (#37655)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-18 13:41:01 -05:00
Kit Langton 4e56998d3c fix(cli): harden managed service election (#37645) 2026-07-18 14:16:55 -04:00
Aiden Cline d625bc86fc feat(core): publish pending MCP status during connect (#37605) 2026-07-18 12:02:21 -05:00
Aiden Cline 5f437a09b0 feat(codemode): native coercion parity in interpreter (#37608) 2026-07-18 12:01:10 -05:00
opencode-agent[bot] 7d4496eafc test(core): stabilize shell progress test (#37643)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-18 11:59:24 -05:00
opencode-agent[bot] 1f2de535aa fix(ai): parse compatible reasoning deltas (#37558)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-18 10:57:10 -05:00
opencode-agent[bot] 529d55b1c3 fix(core): remove session import cycle (#37596)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-18 13:24:47 +00:00
Aiden Cline deb5b144c3 test(ai): record MiniMax messages compatibility (#37604) 2026-07-17 23:22:09 -05:00
opencode-agent[bot] cd3cca0006 feat(core): add runtime MCP controls (#37308)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-17 22:56:52 -05:00
Aiden Cline 08a7080e11 fix(tui): exit composer picker with up arrow (#37601) 2026-07-17 22:22:00 -05:00
Dax Raad f2579c41b6 docs: allow trusted cli installation 2026-07-17 20:49:42 -04:00
Dax Raad c4830c147d fix(core): defer cyclic layer dependencies 2026-07-17 20:25:21 -04:00
Dax Raad 22c23cb44c fix(cli): reduce startup overhead 2026-07-17 20:14:04 -04:00
opencode-agent[bot] 60ce33cde8 fix(cli): simplify service registration lease (#37576)
Co-authored-by: Dax Raad <826656+thdxr@users.noreply.github.com>
2026-07-17 19:47:17 -04:00
opencode-agent[bot] 6ea8247e0f fix(cli): elect managed service by port bind (#37572)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-17 23:12:36 +00:00
Aiden Cline 987242b3e8 fix(core): honor OAuth attempt expiration (#37557) 2026-07-17 16:10:53 -05:00
James Long fafb04ed59 feat(tui): track hue source identity (#37550) 2026-07-17 15:38:22 -04:00
Aiden Cline 87d5b27668 feat(codemode): project OpenAPI schema directions (#37361) 2026-07-17 14:24:50 -05:00
James Long c7a7900ff2 refactor(tui): migrate workflow views to V2 themes (#37526) 2026-07-17 15:18:58 -04:00
opencode-agent[bot] f2a4011371 fix(cli): restore plugin list diagnostics (#37540)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-17 13:42:44 -05:00
388 changed files with 22834 additions and 11342 deletions
+11 -4
View File
@@ -132,18 +132,14 @@
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
"ws": "8.21.0",
},
@@ -900,9 +896,13 @@
"version": "1.17.13",
"dependencies": {
"@fontsource/commit-mono": "5.2.5",
"@fontsource/noto-sans-math": "5.2.5",
"@fontsource/noto-sans-symbols": "5.2.5",
"@fontsource/noto-sans-symbols-2": "5.2.5",
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"effect": "catalog:",
},
@@ -1026,6 +1026,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
@@ -1795,6 +1796,12 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
"@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
"@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
+43 -2
View File
@@ -1,6 +1,6 @@
# @opencode-ai/ai
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
@@ -24,6 +24,45 @@ const program = Effect.gen(function* () {
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Image generation
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Image } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
})
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
```
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
```ts
const program = Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
prompt: "Design a solarpunk rooftop garden, then show me.",
tools: [OpenAI.imageGeneration({ quality: "high" })],
}),
)
return response.message
})
```
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
@@ -32,6 +71,8 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Caching
@@ -182,7 +223,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
+2 -2
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status
Last reviewed: 2026-07-16
Last reviewed: 2026-07-17
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -20,7 +20,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
| 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. | No named compatible 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. |
+12
View File
@@ -161,6 +161,18 @@ const PROVIDERS: ReadonlyArray<Provider> = [
vars: [{ name: "TOGETHER_AI_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
},
{
id: "minimax",
label: "MiniMax",
tier: "compatible",
note: "Anthropic-compatible Messages text/tool recorded tests",
vars: [{ name: "MINIMAX_API_KEY" }],
validate: (env) =>
HttpClientRequest.get("https://api.minimax.io/anthropic/v1/models").pipe(
HttpClientRequest.setHeader("x-api-key", Redacted.value(Redacted.make(env.MINIMAX_API_KEY))),
executeRequest,
),
},
{
id: "mistral",
label: "Mistral",
+34
View File
@@ -0,0 +1,34 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor"
import type { ImageRequest, ImageResponse } from "./image"
import type { LLMError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, LLMError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
})
}),
)
export const ImageClient = {
Service,
layer,
generate,
} as const
+116
View File
@@ -0,0 +1,116 @@
import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute {
readonly id: string
readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
}
export class ImageModel {
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute
readonly defaults?: ImageModelDefaults
constructor(input: ImageModel.Input) {
this.id = input.id
this.provider = input.provider
this.route = input.route
this.defaults = input.defaults
}
static make(input: ImageModel.MakeInput) {
return new ImageModel({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
defaults: input.defaults,
})
}
}
export namespace ImageModel {
export interface Input {
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute
readonly defaults?: ImageModelDefaults
}
export interface MakeInput extends Omit<Input, "id" | "provider"> {
readonly id: string | ModelID
readonly provider: string | ProviderID
}
}
export interface ImageModelDefaults {
readonly providerOptions?: Record<string, Record<string, unknown>>
readonly http?: HttpOptions
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
expected: "Image.Model",
})
export const ImageSize = Schema.Struct({
width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
}).annotate({ identifier: "Image.Size" })
export type ImageSize = Schema.Schema.Type<typeof ImageSize>
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
model: ImageModelSchema,
prompt: Schema.String,
count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
size: Schema.optional(ImageSize),
aspectRatio: Schema.optional(Schema.String),
seed: Schema.optional(Schema.Number),
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
http: Schema.optional(HttpOptions),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
readonly http?: HttpOptions.Input
}
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
images: Schema.Array(GeneratedImage),
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}) {
get image() {
return this.images[0]
}
}
export const request = (input: ImageRequest | ImageRequestInput) => {
if (input instanceof ImageRequest) return input
return new ImageRequest({
...input,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
}
export const generate = (input: ImageRequest | ImageRequestInput) =>
Effect.try({
try: () => request(input),
catch: (error) =>
new LLMError({
module: "Image",
method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
}),
}).pipe(Effect.flatMap(ImageClient.generate))
export const Image = {
request,
generate,
} as const
+4
View File
@@ -1,4 +1,5 @@
export { LLMClient } from "./route/client"
export { ImageClient } from "./image-client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
@@ -10,6 +11,9 @@ export type {
Service as LLMClientService,
} from "./route/client"
export * from "./schema"
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
export { Image } from "./image"
export { Tool, ToolFailure, toDefinitions } from "./tool"
export { ToolRuntime } from "./tool-runtime"
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
@@ -703,7 +703,14 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
providerExecuted: block.type === "server_tool_use",
}),
},
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
[
...events,
LLMEvent.toolInputStart({
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
],
]
}
@@ -561,7 +561,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
hasToolCalls:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasToolCalls,
lifecycle,
tools: result.tools,
reasoningSignatures: Object.fromEntries(
+1
View File
@@ -2,6 +2,7 @@ export * as AnthropicMessages from "./anthropic-messages"
export * as BedrockConverse from "./bedrock-converse"
export * as Gemini from "./gemini"
export * as OpenAIChat from "./openai-chat"
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"
+175 -13
View File
@@ -75,6 +75,9 @@ const OpenAIChatMessage = Schema.Union([
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(Schema.Unknown),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -145,6 +148,9 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
@@ -160,12 +166,23 @@ export const OpenAIChatEvent = Schema.Struct({
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
}
export interface ParserState {
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
}
// =============================================================================
@@ -208,6 +225,20 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.openai?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
@@ -248,14 +279,29 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (reasoning.length === 0) return
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningContent = (() => {
if (reasoning.length === 0) return nativeReasoning
if (field === "reasoning_content") return text
})()
return {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content:
reasoning.length > 0
? reasoning.map((part) => part.text).join("")
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
reasoning_content: reasoningContent,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details,
}
})
@@ -400,6 +446,65 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
})
}
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
}
const detailText = (details: ReadonlyArray<unknown>) => {
const text = details.flatMap((detail) => {
if (!isRecord(detail)) return []
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
return []
})
if (text.length > 0) return text.join("")
}
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
for (const detail of details) {
const previous = result.at(-1)
if (
!isRecord(previous) ||
previous.type !== "reasoning.text" ||
!isRecord(detail) ||
detail.type !== "reasoning.text" ||
conflictingReasoningTextDetails(previous, detail)
) {
result.push(detail)
continue
}
result[result.length - 1] = {
...previous,
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
signature: mergeDetailValue(previous.signature, detail.signature),
format: mergeDetailValue(previous.format, detail.format),
}
}
}
const mergeDetailValue = (previous: unknown, current: unknown) =>
previous || current || (previous !== undefined ? previous : current)
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
conflictingDetailValue(previous.id, current.id) ||
conflictingDetailValue(previous.index, current.index) ||
conflictingDetailValue(previous.format, current.format) ||
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
})
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
@@ -409,25 +514,56 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
let pendingTools = state.pendingTools
let lifecycle = state.lifecycle
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
if (!current && (!id || !name)) {
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[tool.index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
tool.index,
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result)) return yield* result
@@ -436,8 +572,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// JSON parse failures fail the stream at the boundary rather than at halt.
// valid calls and malformed local calls settle independently.
const finished =
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
? yield* ToolStream.finishAll(ADAPTER, tools)
@@ -446,10 +585,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
usage,
finishReason,
lifecycle,
reasoningField,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
},
events,
] as const
@@ -459,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
@@ -482,7 +635,16 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
initial: () => ({
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
}),
step,
onHalt: finishEvents,
},
+208
View File
@@ -0,0 +1,208 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
ImageModel,
GeneratedImage,
ImageResponse,
type ImageRequest,
type ImageModelDefaults,
type ImageRoute,
} from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
import { ProviderShared } from "./shared"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-images"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/images/generations"
export interface OpenAIImageOptions {
readonly quality?: "auto" | "low" | "medium" | "high"
readonly background?: "auto" | "opaque" | "transparent"
readonly moderation?: "auto" | "low"
readonly outputFormat?: "png" | "jpeg" | "webp"
readonly outputCompression?: number
}
const OpenAIImageBody = Schema.Struct({
model: Schema.String,
prompt: Schema.String,
n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
size: Schema.optional(Schema.String),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
})
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(
Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
revised_prompt: Schema.optional(Schema.String),
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
),
})
export interface ModelInput {
readonly id: string
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly defaults?: ImageModelDefaults
}
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
...request.model.defaults?.providerOptions?.openai,
...request.providerOptions?.openai,
})
const body = (request: ImageRequest): OpenAIImageBody => {
const options = providerOptions(request)
return {
model: request.model.id,
prompt: request.prompt,
n: request.count,
size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
quality: options.quality,
background: options.background,
moderation: options.moderation,
output_format: options.outputFormat,
output_compression: options.outputCompression,
}
}
const invalidOutput = (message: string) =>
new LLMError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
return next.toString()
}
const PROTOCOL_BODY_FIELDS = new Set([
"model",
"prompt",
"n",
"size",
"quality",
"background",
"moderation",
"output_format",
"output_compression",
])
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
imageBody: OpenAIImageBody,
overlay: Record<string, unknown> | undefined,
) {
if (!overlay) return imageBody
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
if (reserved.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
)
return mergeJsonRecords(imageBody, overlay) ?? imageBody
})
export const model = (input: ModelInput) => {
const route: ImageRoute = {
id: ADAPTER,
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
if (request.aspectRatio !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
if (request.seed !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
const text = ProviderShared.encodeJson(overlaidBody)
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
url,
body: text,
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
})
const response = yield* execute(
HttpClientRequest.post(url).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(text, "application/json"),
),
)
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
)
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
const images = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
mediaType: `image/${format}`,
data,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
),
)
if (item.url)
return Effect.succeed(
new GeneratedImage({
mediaType: `image/${format}`,
data: item.url,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
return new ImageResponse({
images,
usage:
decoded.usage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
outputTokens: decoded.usage.output_tokens,
totalTokens: decoded.usage.total_tokens,
providerMetadata: { openai: decoded.usage },
}),
providerMetadata: { openai: { outputFormat: format } },
})
}),
}
return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
}
export const OpenAIImages = {
model,
} as const
+82 -22
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Encoding, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -25,6 +25,7 @@ import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
@@ -113,11 +114,24 @@ const OpenAIResponsesTool = Schema.Struct({
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
})
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
input_fidelity: Schema.optional(Schema.Literals(["low", "high"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
const OpenAIResponsesToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({ type: Schema.tag("image_generation") }),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -128,7 +142,7 @@ const OpenAIResponsesCoreFields = {
model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(OpenAIResponsesTool),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
@@ -194,6 +208,8 @@ const OpenAIResponsesStreamItem = Schema.Struct({
outputs: Schema.optional(Schema.Unknown),
server_label: Schema.optional(Schema.String),
output: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
error: Schema.optional(Schema.Unknown),
encrypted_content: optionalNull(Schema.String),
})
@@ -258,21 +274,41 @@ const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
}
const nativeImageTool = (tool: ToolDefinition) => {
const native = nativeImageToolInput(tool)
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* invalid("OpenAI Responses image generation tool options are invalid")
}
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
}
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) => ({ type: "function" as const, name }),
tool: (name) =>
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
})
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
@@ -420,6 +456,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const itemID = hostedToolItemID(part)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.name === "image_generation" && part.result.type === "content") {
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
})
}
if (itemID) hostedToolReferences.add(itemID)
continue
}
@@ -485,10 +528,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -574,14 +617,29 @@ const isReasoningItem = (
// Round-trip the full item as the structured result so consumers can extract
// outputs / sources / status without re-decoding.
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) {
const isError = typeof item.error !== "undefined" && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
)
return {
type: "content" as const,
value: [
{
type: "file" as const,
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
mime: `image/${item.output_format ?? "png"}`,
},
],
}
}
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
}
})
const hostedToolEvents = (
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
): ReadonlyArray<LLMEvent> => {
) {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id })
return [
@@ -595,12 +653,12 @@ const hostedToolEvents = (
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: hostedToolResult(item),
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
]
}
})
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -835,7 +893,9 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
{
...state,
lifecycle,
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
hasFunctionCall:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
},
events,
@@ -845,7 +905,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isHostedToolItem(item)) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(...hostedToolEvents(item))
events.push(...(yield* hostedToolEvents(item)))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const reasoningDelta = (
providerMetadata?: ProviderMetadata,
): State => {
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text }))
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
return started
}
@@ -0,0 +1,20 @@
import { Schema } from "effect"
const dimensions = (value: string) => {
const match = /^(\d+)x(\d+)$/.exec(value)
if (!match) return undefined
return { width: Number(match[1]), height: Number(match[2]) }
}
export const Size = Schema.String.check(
Schema.makeFilter((value) => {
if (value === "auto") return undefined
const parsed = dimensions(value)
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
}),
)
export const OpenAIImage = {
Size,
} as const
+40 -32
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
})
@@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
text,
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
}),
),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -122,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
const current = tools[key]
const id = delta.id ?? current?.id
const name = delta.name ?? current?.name
const id = current?.id ?? delta.id
const name = current?.name ?? delta.name
if (!id || !name) return eventError(route, missingToolMessage)
const tool = {
@@ -158,8 +176,9 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and return the optional public `tool-call` event. Missing keys are
* a no-op because some providers emit stop events for non-tool content blocks.
* from state, and return either a call or a non-executable local input error.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
Effect.gen(function* () {
@@ -167,10 +186,7 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
if (!tool) return { tools }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool),
],
events: finishEvents(tool, yield* toolCall(route, tool)),
}
})
@@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
if (!tool) return { tools }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool, input),
],
events: finishEvents(tool, yield* toolCall(route, tool, input)),
}
})
/**
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
* not emit per-tool stop events, so all accumulated calls finish when the choice
* receives a terminal `finish_reason`.
* not emit per-tool stop events, so all accumulated calls finish independently
* when the choice receives a terminal `finish_reason`.
*/
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
Effect.gen(function* () {
@@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
return {
tools: empty<K>(),
events: yield* Effect.forEach(pending, (tool) =>
toolCall(route, tool).pipe(
Effect.map((call) => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
call,
]),
),
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
).pipe(Effect.map((events) => events.flat())),
}
})
+11 -1
View File
@@ -16,13 +16,17 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
/input token count.*exceeds the maximum/i,
/tokens in request more than max tokens allowed/i,
/maximum prompt length is \d+/i,
/reduce the length of the messages/i,
/maximum context length is \d+ tokens/i,
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
/exceeds the limit of \d+/i,
/exceeds the available context size/i,
/greater than the context length/i,
@@ -34,11 +38,17 @@ const patterns = [
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
+57 -2
View File
@@ -1,12 +1,14 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { Route, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export type { OpenAIImageOptions } from "../protocols/openai-images"
export const id = ProviderID.make("openai")
@@ -20,8 +22,44 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
readonly image?: ImageConfig
}
export interface ImageConfig {
readonly providerOptions?: OpenAIImageOptions
}
export interface ImageGenerationOptions {
readonly action?: "auto" | "generate" | "edit"
readonly background?: "auto" | "opaque" | "transparent"
readonly inputFidelity?: "low" | "high"
readonly outputCompression?: number
readonly outputFormat?: "png" | "jpeg" | "webp"
readonly partialImages?: number
readonly quality?: "auto" | "low" | "medium" | "high"
readonly size?: string
}
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
ToolDefinition.make({
name: "image_generation",
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
native: {
openai: {
type: "image_generation",
action: options.action,
background: options.background,
input_fidelity: options.inputFidelity,
output_compression: options.outputCompression,
output_format: options.outputFormat,
partial_images: options.partialImages,
quality: options.quality,
size: options.size,
},
},
})
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
@@ -35,7 +73,7 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
return rest
}
@@ -55,6 +93,21 @@ export const configure = (input: Config = {}) => {
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
defaults: {
providerOptions:
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
},
})
return {
id,
@@ -62,6 +115,7 @@ export const configure = (input: Config = {}) => {
responses,
responsesWebSocket,
chat,
image,
configure,
}
}
@@ -97,3 +151,4 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
export const image = provider.image
+25 -7
View File
@@ -41,13 +41,31 @@ export const protocol = Protocol.make({
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
Effect.map(
(body) =>
({
...body,
...bodyOptions(request.providerOptions?.openrouter),
}) as OpenRouterBody,
),
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
const messages = body.messages.map((message) => {
if (message.role !== "assistant") return message
const source = sourceAssistants[assistantIndex++]
const reasoning = source?.content
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("")
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
return {
...message,
reasoning_content: undefined,
reasoning_text: undefined,
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
reasoning_details: reasoningDetails,
}
})
return {
...body,
messages,
...bodyOptions(request.providerOptions?.openrouter),
} as OpenRouterBody
}),
),
},
stream: OpenAIChat.protocol.stream,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
readonly request: LLMRequest
readonly request: { readonly http?: HttpOptions }
readonly method: "POST" | "GET"
readonly url: string
readonly body: string
+18
View File
@@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
@@ -149,6 +150,15 @@ export const ToolInputEnd = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
/** A local tool call whose final input could not be decoded. */
export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
name: Schema.String,
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
@@ -216,6 +226,7 @@ const llmEventTagged = Schema.Union([
ToolInputStart,
ToolInputDelta,
ToolInputEnd,
ToolInputError,
ToolCall,
ToolResult,
ToolError,
@@ -253,6 +264,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
ToolResult.make({
@@ -283,6 +296,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
toolInputError: llmEventTagged.guards["tool-input-error"],
toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
@@ -548,6 +562,10 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
return reduceToolInputDelta(next, event)
case "tool-input-end":
return reduceToolInputEnd(next, event)
case "tool-input-error": {
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
return { ...next, toolInputs }
}
case "tool-call":
return reduceToolCall(next, event)
case "tool-result":
@@ -0,0 +1,40 @@
{
"version": 1,
"metadata": {
"provider": "minimax",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"transport": "http",
"model": "MiniMax-M3",
"tags": [
"prefix:anthropic-compatible-messages",
"provider:minimax",
"protocol:anthropic-messages",
"text",
"golden"
],
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-text",
"recordedAt": "2026-07-18T03:42:22.893Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"1a0b363d0882af316faebcec4d4855a8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":53,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":53,\"output_tokens\":2,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -0,0 +1,41 @@
{
"version": 1,
"metadata": {
"provider": "minimax",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"transport": "http",
"model": "MiniMax-M3",
"tags": [
"prefix:anthropic-compatible-messages",
"provider:minimax",
"protocol:anthropic-messages",
"tool",
"tool-call",
"golden"
],
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-call",
"recordedAt": "2026-07-18T03:42:23.876Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6731ecc323233459d1792df9a733dd98\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":404,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_vkxtif4epmvm_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":290,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -0,0 +1,60 @@
{
"version": 1,
"metadata": {
"provider": "minimax",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"transport": "http",
"model": "MiniMax-M3",
"tags": [
"prefix:anthropic-compatible-messages",
"provider:minimax",
"protocol:anthropic-messages",
"tool",
"tool-loop",
"golden"
],
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-loop",
"recordedAt": "2026-07-18T03:42:25.248Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"3807fa12f9ecb9357df511e099da6da0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":417,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":303,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_function_yr64rwmre4gr_1\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"92f8a1e86f29946eb2699d40a088fc08\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":41,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":41,\"output_tokens\":4,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"model": "anthropic/claude-sonnet-4.6",
"tags": [
"prefix:openai-compatible-chat",
"provider:openrouter",
"protocol:openai-chat",
"reasoning"
],
"name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../src"
import { OpenAI } from "../src/providers"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
describe("Image", () => {
it.effect("generates images through the OpenAI Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({
apiKey: "test",
baseURL: "https://api.openai.test/v1",
queryParams: { "api-version": "v1" },
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
}).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: {
openai: { quality: "high", outputFormat: "webp" },
},
http: {
body: { request_metadata: "value" },
headers: { "x-request": "yes" },
query: { trace: "1" },
},
})
expect(response.images).toHaveLength(2)
expect(response.image?.mediaType).toBe("image/webp")
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
expect(response.usage?.totalTokens).toBe(12)
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://api.openai.test/v1/images/generations?api-version=v1&trace=1")
expect(request.headers.get("authorization")).toBe("Bearer test")
expect(request.headers.get("x-default")).toBe("yes")
expect(request.headers.get("x-request")).toBe("yes")
expect(JSON.parse(input.text)).toEqual({
model: "gpt-image-2",
prompt: "A robot tending a rooftop garden",
n: 2,
size: "1024x1024",
quality: "high",
output_format: "webp",
deployment: "test",
request_metadata: "value",
})
return input.respond(
JSON.stringify({
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
output_format: "webp",
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("rejects invalid common and OpenAI image options locally", () =>
Image.generate({
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: -1,
size: { width: -1, height: 0.5 },
providerOptions: { openai: { outputCompression: 101 } },
}).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidRequest")
}),
),
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
),
),
),
)
})
+24 -2
View File
@@ -3,8 +3,30 @@ import { isContextOverflow } from "../src"
import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => {
test("classifies Z.AI GLM token limit messages as context overflow", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Too many tokens",
"Token limit exceeded",
]
expect(messages.every(isContextOverflow)).toBe(true)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
"Rate limit exceeded, please retry after 30 seconds.",
"Too many requests. Please slow down.",
]
expect(messages.some(isContextOverflow)).toBe(false)
})
test("classifies V1 plain-text rate limit fallbacks", () => {
@@ -484,6 +484,30 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
},
{ type: "content_block_stop", index: 0 },
)
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error).toBeInstanceOf(LLMError)
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
}),
)
it.effect("fails with a typed provider error for stream error frames", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -303,6 +303,32 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits malformed tool input as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
["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 === "tool-input-error")).toMatchObject({
id: "tool_1",
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
}),
)
it.effect("decodes reasoning deltas", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+54 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { LLM, LLMEvent } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
@@ -83,6 +83,59 @@ describe("Cloudflare", () => {
}),
)
it.effect("preserves reasoning details for AI Gateway continuation", () =>
Effect.gen(function* () {
const model = CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "test-gateway",
apiKey: "test-token",
}).model("anthropic/claude-sonnet-4.6")
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "Thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(
sseEvents(
deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
deltaChunk({ reasoning_details: [details[2]] }),
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
])
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
@@ -1,4 +1,5 @@
import * as Anthropic from "../../src/providers/anthropic"
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import * as Google from "../../src/providers/google"
import * as OpenAI from "../../src/providers/openai"
@@ -17,6 +18,11 @@ const anthropic = Anthropic.configure({
})
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
const anthropicOpus = anthropic.model("claude-opus-4-7")
const minimax = AnthropicCompatible.configure({
apiKey: process.env.MINIMAX_API_KEY ?? "fixture",
baseURL: "https://api.minimax.io/anthropic/v1",
provider: "minimax",
}).model("MiniMax-M3")
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
const gemini = google.model("gemini-2.5-flash")
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
@@ -108,6 +114,15 @@ describeRecordedGoldenScenarios([
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
name: "MiniMax M3 Anthropic-compatible",
prefix: "anthropic-compatible-messages",
protocol: "anthropic-messages",
model: minimax,
requires: ["MINIMAX_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
scenarios: ["text", "tool-call", "tool-loop"],
},
{
name: "Gemini 2.5 Flash",
prefix: "gemini",
@@ -0,0 +1,142 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
const cases = [
{
name: "OpenRouter",
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
structured: true,
},
{
name: "Vercel AI Gateway",
model: OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
structured: true,
},
] as const
for (const item of cases) {
const recorded = recordedTests({
prefix: "openai-compatible-chat",
provider: item.model.provider,
protocol: "openai-chat",
requires: item.requires,
tags: ["reasoning"],
metadata: { model: item.model.id },
})
describe(`${item.name} reasoning recorded`, () => {
recorded.effect.with(
"streams scalar reasoning",
{ cassette: item.cassette },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: item.model,
system: "Think through the arithmetic, then reply with only the final integer.",
prompt: "What is 173 multiplied by 219?",
generation: { maxTokens: 1536, temperature: 0 },
}),
)
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
if (!item.structured) return
const details = metadata?.openai?.reasoningDetails
if (!Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
const replayDetails =
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
expect(Array.isArray(replayDetails)).toBe(true)
if (!Array.isArray(replayDetails)) return
expect(replayDetails).toEqual(details)
expect(replayDetails).toHaveLength(1)
expect(replayDetails[0]).toMatchObject({
type: "reasoning.text",
text: response.reasoning,
signature: expect.any(String),
})
}),
30_000,
)
recorded.effect.with(
"continues signed reasoning through a tool loop",
{ cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: `${item.cassette}-tool-loop`,
model: item.model,
maxTokens: 1536,
temperature: false,
}),
)
expectWeatherToolLoop(events)
expect(
LLMResponse.text({
events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
}).trim(),
).toMatch(/^Paris is sunny\.?$/)
const details = events
.filter(LLMEvent.is.reasoningEnd)
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
.find(Array.isArray)
expect(Array.isArray(details)).toBe(item.structured)
if (!item.structured || !Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
}),
60_000,
)
})
}
+451 -17
View File
@@ -540,28 +540,401 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const body = sseEvents(
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
for (const field of fields) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { [field]: "thinking" } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe("Hello")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: field },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
}
}),
)
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ 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" } }],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
],
},
finish_reason: "tool_calls",
},
],
},
),
),
),
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "thinking",
reasoning_details: details,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"query":"weather"}' },
},
],
},
])
}),
)
it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
it.effect("preserves unknown reasoning details while using scalar display text", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
}),
)
it.effect("uses scalar display text for signature-only reasoning details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning: "scalar" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
}),
)
it.effect("preserves an explicitly empty reasoning details array", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: [] },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
it.effect("attaches signature-only details that arrive after content", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
}),
)
it.effect("preserves metadata-only reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
])
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
it.effect("flushes details-only display reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("summary")
expect(response.message.content).toEqual([
{ type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
])
}),
)
it.effect("replays details from multiple reasoning parts in order", () =>
Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "first",
providerMetadata: { openai: { reasoningDetails: [first] } },
},
{
type: "reasoning",
text: "second",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
},
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
])
}),
)
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "A",
providerMetadata: { openai: { reasoningDetails: [detail] } },
},
{ type: "reasoning", text: "B" },
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
])
}),
)
it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: [{ type: "reasoning", text: "thinking" }],
native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
}),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
])
}),
)
@@ -602,6 +975,67 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("ignores empty identity fields on later tool call deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("buffers tool call deltas until the function name arrives", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("fails when a buffered tool call never receives a function name", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
}),
deltaChunk({}, "tool_calls"),
)
const error = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -0,0 +1,40 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src"
import { OpenAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const model = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
image: {
providerOptions: {
quality: "low",
outputFormat: "jpeg",
outputCompression: 10,
},
},
}).image("gpt-image-1-mini")
const recorded = recordedTests({
prefix: "openai-images",
provider: "openai",
protocol: "openai-images",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat black circle centered on a plain white background.",
size: { width: 1024, height: 1024 },
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toBeInstanceOf(Uint8Array)
expect(response.image?.data.length).toBeGreaterThan(0)
}),
)
})
@@ -0,0 +1,66 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src"
import { OpenAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const openai = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
})
const recorded = recordedTests({
prefix: "openai-responses-images",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Responses image generation recorded", () => {
recorded.effect("generates and edits an image with the hosted tool", () =>
Effect.gen(function* () {
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
const tools = [
OpenAI.imageGeneration({
action: "auto",
quality: "low",
size: "1024x1024",
outputFormat: "jpeg",
outputCompression: 10,
partialImages: 0,
}),
]
const response = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial],
tools,
toolChoice: "image_generation",
}),
)
const result = response.events.find(LLMEvent.is.toolResult)
expect(result).toBeDefined()
expect(result?.providerExecuted).toBe(true)
expect(result?.result.type).toBe("content")
if (result?.result.type !== "content") return
expect(result.result.value).toHaveLength(1)
expect(result.result.value[0]?.type).toBe("file")
if (result.result.value[0]?.type !== "file") return
expect(result.result.value[0].mime).toBe("image/jpeg")
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
const edited = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
tools,
toolChoice: "image_generation",
}),
)
const editedResult = edited.events.find(LLMEvent.is.toolResult)
expect(editedResult?.result.type).toBe("content")
if (editedResult?.result.type !== "content") return
expect(editedResult.result.value[0]?.type).toBe("file")
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -58,6 +58,39 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
toolChoice: "image_generation",
}),
)
expect(prepared.body.tools).toEqual([
{ type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
])
expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
}),
)
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("image generation tool options are invalid")
}),
)
it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () {
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
@@ -1103,6 +1136,48 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.user("Generate a black triangle."),
Message.assistant([
ToolCallPart.make({
id: "ig_1",
name: "image_generation",
input: {},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
ToolResultPart.make({
id: "ig_1",
name: "image_generation",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
]),
Message.user("Make it blue."),
],
tools: [imageTool],
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
{ role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -1259,6 +1334,69 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ 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" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("settles malformed function arguments when output_item.added is absent", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
@@ -1298,6 +1436,59 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
},
{ type: "response.completed", response: {} },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("invalid image base64")
}),
)
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
Effect.gen(function* () {
const item = {
+99 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
@@ -53,4 +53,102 @@ describe("OpenRouter", () => {
})
}),
)
it.effect("preserves manually supplied reasoning details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
},
]),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "Thinking",
reasoning_details: details,
},
])
}),
)
it.effect("preserves opaque and duplicate continuation details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant({
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
])
}),
)
it.effect("does not merge distinct adjacent reasoning text blocks", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant({
type: "reasoning",
text: "AB",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
])
}),
)
it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
}),
)
})
+2 -23
View File
@@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
throw new Error("Weather tool loop exceeded 10 steps")
})
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
const content: ContentPart[] = []
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
const type = event.type === "text-delta" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) {
content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
} else {
content.push({ type, text: event.text })
}
continue
}
if (event.type === "text-end" || event.type === "reasoning-end") {
const type = event.type === "text-end" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
continue
}
if (event.type === "tool-call") content.push(event)
}
return content
}
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
+8 -2
View File
@@ -3,6 +3,8 @@ import { Layer } from "effect"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
import { ImageClient } from "../src/image-client"
import type { Service as ImageClientService } from "../src/image-client"
import type { Service as LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@@ -15,7 +17,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -81,6 +83,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
),
)
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
return Layer.mergeAll(
deps,
LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.layer.pipe(Layer.provide(deps)),
)
},
})
+15
View File
@@ -95,4 +95,19 @@ describe("LLMResponse reducer", () => {
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
})
test("clears malformed tool input without appending an executable call", () => {
const state = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query":"partial' }),
LLMEvent.toolInputError({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
}),
])
expect(state.toolInputs).toEqual({})
expect(state.message.content).toEqual([])
})
})
+94
View File
@@ -36,6 +36,33 @@ describe("ToolStream", () => {
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query"' },
"missing tool",
)
if (ToolStream.isError(first)) return yield* first
const second = ToolStream.appendOrStart(
ADAPTER,
first.tools,
0,
{ id: "", name: "", text: ':"weather"}' },
"missing tool",
)
if (ToolStream.isError(second)) return yield* second
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
@@ -64,6 +91,73 @@ describe("ToolStream", () => {
}),
)
it.effect("finalizes malformed local input as a non-executable tool error", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: '{"query":"partial',
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished).toEqual({
tools: {},
events: [
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
},
],
})
}),
)
it.effect("preserves valid siblings when one parallel input is malformed", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
name: "lookup",
input: '{"query":"weather"}',
})
const tools = ToolStream.start(valid, 1, {
id: "call_invalid",
name: "lookup",
input: '{"query":"partial',
})
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
expect(finished).toEqual({
tools: {},
events: [
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{
type: "tool-input-error",
id: "call_invalid",
name: "lookup",
raw: '{"query":"partial',
},
],
})
}),
)
it.effect("keeps malformed provider-executed input terminal", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "web_search",
input: '{"query":"partial',
providerExecuted: true,
})
const result = yield* Effect.exit(ToolStream.finish(ADAPTER, tools, "item_1"))
expect(result._tag).toBe("Failure")
}),
)
it.effect("preserves providerExecuted and clears all tools", () =>
Effect.gen(function* () {
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
@@ -1615,6 +1615,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
data-background-surface="prompt"
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
@@ -446,6 +446,7 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
return (
<button
data-action="prompt-project"
data-background-surface="project-selector"
type="button"
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.controller.add()}
@@ -464,6 +465,7 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
<button
{...rest}
data-action="prompt-project"
data-background-surface="project-selector"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
@@ -4,10 +4,14 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div
data-component="session-new-design"
data-background-surface="shell"
class="relative size-full overflow-hidden bg-v2-background-bg-deep "
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse [&>g>g>g]:!opacity-[0.45]" />
<div class="mt-8">{props.children}</div>
</div>
</div>
@@ -0,0 +1,36 @@
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { showToast } from "@/utils/toast"
export function useSettingsBackgroundImage() {
const platform = usePlatform()
const language = useLanguage()
const [state, setState] = createStore({ busy: false })
const run = async (action: (() => Promise<unknown>) | undefined) => {
if (!action || state.busy) return
setState("busy", true)
try {
await action()
} catch (error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
} finally {
setState("busy", false)
}
}
return {
available: !!platform.selectBackgroundImage,
active: () => platform.backgroundImage?.() ?? false,
get busy() {
return state.busy
},
select: () => run(platform.selectBackgroundImage),
clear: () => run(platform.clearBackgroundImage),
}
}
@@ -31,6 +31,7 @@ import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
import { useSettingsBackgroundImage } from "./settings-background-image"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
@@ -87,6 +88,7 @@ export const SettingsGeneral: Component = () => {
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const backgroundImage = useSettingsBackgroundImage()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
@@ -499,6 +501,24 @@ export const SettingsGeneral: Component = () => {
/>
</SettingsRow>
<Show when={backgroundImage.available}>
<SettingsRow
title={language.t("settings.general.row.backgroundImage.title")}
description={language.t("settings.general.row.backgroundImage.description")}
>
<div class="flex items-center gap-2">
<Button size="small" variant="secondary" disabled={backgroundImage.busy} onClick={backgroundImage.select}>
{language.t("settings.general.row.backgroundImage.choose")}
</Button>
<Show when={backgroundImage.active()}>
<Button size="small" variant="ghost" disabled={backgroundImage.busy} onClick={backgroundImage.clear}>
{language.t("settings.general.row.backgroundImage.remove")}
</Button>
</Show>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
@@ -29,6 +29,7 @@ import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import { useSettingsBackgroundImage } from "../settings-background-image"
import "./settings-v2.css"
let demoSoundState = {
@@ -88,6 +89,7 @@ export const SettingsGeneralV2: Component<{
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const backgroundImage = useSettingsBackgroundImage()
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
@@ -460,6 +462,29 @@ export const SettingsGeneralV2: Component<{
/>
</SettingsRowV2>
<Show when={backgroundImage.available}>
<SettingsRowV2
title={language.t("settings.general.row.backgroundImage.title")}
description={language.t("settings.general.row.backgroundImage.description")}
>
<div class="flex items-center gap-2">
<ButtonV2
size="normal"
variant="neutral"
disabled={backgroundImage.busy}
onClick={backgroundImage.select}
>
{language.t("settings.general.row.backgroundImage.choose")}
</ButtonV2>
<Show when={backgroundImage.active()}>
<ButtonV2 size="normal" variant="ghost" disabled={backgroundImage.busy} onClick={backgroundImage.clear}>
{language.t("settings.general.row.backgroundImage.remove")}
</ButtonV2>
</Show>
</div>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
+1
View File
@@ -227,6 +227,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<header
data-background-surface="shell"
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
classList={{
"shrink-0 relative flex flex-row": true,
+12
View File
@@ -112,6 +112,18 @@ type PlatformBase = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
/** Load and apply the saved app background image. */
loadBackgroundImage?(): Promise<boolean>
/** Whether the app currently has a background image. */
backgroundImage?: Accessor<boolean>
/** Select and apply an app background image. */
selectBackgroundImage?(): Promise<boolean>
/** Clear the saved app background image. */
clearBackgroundImage?(): Promise<void>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
+37
View File
@@ -1,6 +1,7 @@
// @refresh reload
import * as Sentry from "@sentry/solid"
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -8,6 +9,12 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import {
clearWebBackgroundImage,
loadWebBackgroundImage,
saveWebBackgroundImage,
selectWebBackgroundImage,
} from "@/utils/web-background-image"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -119,6 +126,21 @@ const clearAuthToken = () => {
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const [backgroundImage, setBackgroundImage] = createSignal(false)
let backgroundImageUrl: string | undefined
const applyBackgroundImage = (image: Blob | null) => {
if (backgroundImageUrl) URL.revokeObjectURL(backgroundImageUrl)
backgroundImageUrl = image ? URL.createObjectURL(image) : undefined
setBackgroundImage(!!backgroundImageUrl)
document.documentElement.toggleAttribute("data-background-image", !!backgroundImageUrl)
if (backgroundImageUrl) {
document.documentElement.style.setProperty("--app-background-image", `url("${backgroundImageUrl}")`)
return true
}
document.documentElement.style.removeProperty("--app-background-image")
return false
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -132,8 +154,23 @@ const platform: Platform = {
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
backgroundImage,
async loadBackgroundImage() {
return applyBackgroundImage(await loadWebBackgroundImage())
},
async selectBackgroundImage() {
const file = await selectWebBackgroundImage()
if (!file) return backgroundImage()
return applyBackgroundImage(await saveWebBackgroundImage(file))
},
async clearBackgroundImage() {
await clearWebBackgroundImage()
applyBackgroundImage(null)
},
}
void platform.loadBackgroundImage?.()
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
+4
View File
@@ -704,6 +704,10 @@ export const dict = {
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
"settings.general.row.terminalFont.title": "خط الطرفية",
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
+4
View File
@@ -713,6 +713,10 @@ export const dict = {
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
"settings.general.row.terminalFont.title": "Fonte do terminal",
"settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
+4
View File
@@ -778,6 +778,10 @@ export const dict = {
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
"settings.general.row.terminalFont.title": "Font terminala",
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
+4
View File
@@ -773,6 +773,10 @@ export const dict = {
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
"settings.general.row.terminalFont.title": "Terminalskrifttype",
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd",
+4
View File
@@ -724,6 +724,10 @@ export const dict = {
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
"settings.general.row.terminalFont.title": "Terminalschriftart",
"settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
+4
View File
@@ -862,6 +862,10 @@ export const dict = {
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.font.title": "Code Font",
"settings.general.row.font.description": "Customise the font used in code blocks",
"settings.general.row.terminalFont.title": "Terminal Font",
+4
View File
@@ -781,6 +781,10 @@ export const dict = {
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
"settings.general.row.terminalFont.title": "Fuente del terminal",
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento",
+4
View File
@@ -720,6 +720,10 @@ export const dict = {
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
"settings.general.row.terminalFont.title": "Police du terminal",
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi",
+4
View File
@@ -709,6 +709,10 @@ export const dict = {
"settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
"settings.general.row.terminalFont.title": "ターミナルのフォント",
"settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
+4
View File
@@ -580,6 +580,10 @@ export const dict = {
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
"settings.general.row.terminalFont.title": "터미널 글꼴",
"settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
+4
View File
@@ -654,6 +654,10 @@ export const dict = {
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
"settings.general.row.terminalFont.title": "Terminalskrift",
"settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd",
+4
View File
@@ -714,6 +714,10 @@ export const dict = {
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
"settings.general.row.terminalFont.title": "Czcionka terminala",
"settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji",
+4
View File
@@ -778,6 +778,10 @@ export const dict = {
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
"settings.general.row.terminalFont.title": "Шрифт терминала",
"settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
+4
View File
@@ -771,6 +771,10 @@ export const dict = {
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
"settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล",
"settings.general.row.terminalFont.description": "ปรับแต่งฟอนต์ที่ใช้ในเทอร์มินัล",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
+4
View File
@@ -784,6 +784,10 @@ export const dict = {
"settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
"settings.general.row.terminalFont.title": "Terminal yazı tipi",
"settings.general.row.terminalFont.description": "Terminalde kullanılan yazı tipini özelleştirin",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",
+4
View File
@@ -870,6 +870,10 @@ export const dict = {
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
"settings.general.row.terminalFont.title": "Шрифт термінала",
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
"settings.general.row.followup.title": "Поведінка продовження",
+4
View File
@@ -768,6 +768,10 @@ export const dict = {
"settings.general.row.font.description": "自定义代码块使用的字体",
"settings.general.row.terminalFont.title": "终端字体",
"settings.general.row.terminalFont.description": "自定义终端使用的字体",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为",
+4
View File
@@ -763,6 +763,10 @@ export const dict = {
"settings.general.row.font.description": "自訂程式碼區塊使用的字型",
"settings.general.row.terminalFont.title": "終端機字型",
"settings.general.row.terminalFont.description": "自訂終端機使用的字型",
"settings.general.row.backgroundImage.title": "Background image",
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
"settings.general.row.backgroundImage.choose": "Choose image",
"settings.general.row.backgroundImage.remove": "Remove",
"settings.general.row.uiFont.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為",
+56
View File
@@ -3,6 +3,62 @@
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
html[data-background-image] body {
background-image: linear-gradient(rgb(0 0 0 / 30%), rgb(0 0 0 / 30%)), var(--app-background-image);
background-color: transparent !important;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
html[data-background-image] #root {
background-color: transparent !important;
}
html[data-background-image] [data-background-surface="shell"] {
background-color: transparent !important;
}
html[data-background-image] [data-background-surface="content"] {
background-color: color-mix(in srgb, var(--background-base) 68%, transparent) !important;
}
html[data-background-image] [data-background-surface="panel"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 56%, transparent) !important;
}
html[data-background-image] [data-background-surface="prompt"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
backdrop-filter: blur(16px);
}
html[data-background-image] [data-background-surface="project-selector"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--v2-border-border-base) 70%, transparent);
backdrop-filter: blur(16px);
}
html[data-background-image] [data-background-surface="prompt"] [data-background-surface="project-selector"] {
background-color: transparent !important;
box-shadow: none;
backdrop-filter: none;
}
html[data-background-image] [data-background-surface="workspace-bar"] {
padding-inline: 6px;
padding-block: 2px;
border-radius: 6px;
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--v2-border-border-base) 70%, transparent);
backdrop-filter: blur(16px);
}
html[data-background-image] [data-background-surface="workspace-bar"] [data-background-surface="project-selector"] {
background-color: transparent !important;
box-shadow: none;
backdrop-filter: none;
}
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
+4 -1
View File
@@ -598,7 +598,10 @@ export function NewHome() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div
data-background-surface="panel"
class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1"
>
<ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
+1
View File
@@ -26,6 +26,7 @@ export default function NewLayout(props: ParentProps) {
return (
<div
data-background-surface="shell"
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
+5 -1
View File
@@ -2246,7 +2246,10 @@ export default function LegacyLayout(props: ParentProps) {
)
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<div
data-background-surface="shell"
class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
>
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
@@ -2344,6 +2347,7 @@ export default function LegacyLayout(props: ParentProps) {
}}
>
<main
data-background-surface="content"
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
+2
View File
@@ -180,9 +180,11 @@ export default function NewSessionPage() {
/>
<Show when={projectController.selected()}>
<div
data-background-surface={showWorkspaceBar() ? "workspace-bar" : undefined}
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
classList={{
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
"w-fit max-w-full self-center": showWorkspaceBar(),
"justify-start": !showWorkspaceBar(),
}}
>
+1
View File
@@ -335,6 +335,7 @@ function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
return (
<div
data-background-surface={props.newLayout ? "panel" : undefined}
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": props.newLayout,
@@ -0,0 +1,33 @@
const cacheName = "opencode-background-image-v1"
const maxBytes = 20 * 1024 * 1024
function key() {
return new URL("/__opencode/background-image", location.origin).toString()
}
export async function loadWebBackgroundImage() {
const response = await (await caches.open(cacheName)).match(key())
return response?.blob() ?? null
}
export async function saveWebBackgroundImage(file: File) {
if (!file.type.startsWith("image/")) throw new Error("Unsupported background image format")
if (file.size > maxBytes) throw new Error("Background images must be 20 MB or smaller")
await (await caches.open(cacheName)).put(key(), new Response(file, { headers: { "Content-Type": file.type } }))
return file
}
export async function clearWebBackgroundImage() {
await (await caches.open(cacheName)).delete(key())
}
export function selectWebBackgroundImage() {
return new Promise<File | null>((resolve) => {
const input = document.createElement("input")
input.type = "file"
input.accept = "image/avif,image/bmp,image/gif,image/jpeg,image/png,image/webp"
input.onchange = () => resolve(input.files?.[0] ?? null)
input.oncancel = () => resolve(null)
input.click()
})
}
+1 -14
View File
@@ -12,16 +12,7 @@
],
"exports": {
"./daemon": "./src/daemon.ts",
"./mini": "./src/mini/index.ts",
"./mini/footer.command": "./src/mini/footer.command.tsx",
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
"./mini/footer.question": "./src/mini/footer.question.tsx",
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
"./mini/footer.view": "./src/mini/footer.view.tsx",
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
"./mini/*": "./src/mini/*.ts",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
"scripts": {
@@ -40,18 +31,14 @@
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
"ws": "8.21.0"
},
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
import childProcess from "node:child_process"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { createRequire } from "node:module"
import { fileURLToPath } from "node:url"
const directory = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
const command = Object.keys(packageJson.bin ?? {})[0]
if (!command) throw new Error("OpenCode package does not declare a binary")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
const sourceBinary = platform === "windows" ? `${command}.exe` : command
const targetBinary = path.resolve(directory, packageJson.bin[command])
const dependencies = packageJson.optionalDependencies ?? {}
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
return result.status === 0 && (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const script =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
function isMusl() {
if (platform !== "linux") return false
try {
if (fs.existsSync("/etc/alpine-release")) return true
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
} catch {
return false
}
}
function packageNames() {
const baseline = arch === "x64" && !supportsAvx2()
const names =
platform === "linux"
? isMusl()
? arch === "x64"
? baseline
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
: [`${base}-musl`, base]
: arch === "x64"
? baseline
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
: [base, `${base}-musl`]
: arch === "x64"
? baseline
? [`${base}-baseline`, base]
: [base, `${base}-baseline`]
: [base]
return names.filter((name) => dependencies[name])
}
function copyBinary(source) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
try {
fs.linkSync(source, targetBinary)
} catch {
fs.copyFileSync(source, targetBinary)
}
fs.chmodSync(targetBinary, 0o755)
}
function resolveBinary(name) {
const packagePath = require.resolve(`${name}/package.json`)
return path.join(path.dirname(packagePath), "bin", sourceBinary)
}
function installPackage(name) {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
try {
const result = childProcess.spawnSync(
"npm",
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
{ stdio: "inherit", windowsHide: true },
)
if (result.status !== 0) return false
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
return true
} finally {
fs.rmSync(temp, { recursive: true, force: true })
}
}
function verifyBinary() {
return (
childProcess.spawnSync(targetBinary, ["--version"], {
stdio: "ignore",
windowsHide: true,
}).status === 0
)
}
function main() {
const names = packageNames()
for (const name of names) {
try {
copyBinary(resolveBinary(name))
if (verifyBinary()) return
} catch {
if (installPackage(name) && verifyBinary()) return
}
}
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
}
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
+13 -2
View File
@@ -32,12 +32,23 @@ async function publishDistribution(input: { root: string; name: string; binary:
if (!version) throw new Error(`No binary packages found for ${input.name}`)
await $`mkdir -p ${input.root}/${input.name}/bin`
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
[
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
'echo "" >&2',
'echo "This occurs when installation scripts are disabled." >&2',
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
"exit 1",
"",
].join("\n"),
)
await Bun.file(`${input.root}/${input.name}/package.json`).write(
JSON.stringify(
{
name: input.name,
bin: { [input.binary]: `./bin/${input.binary}` },
bin: { [input.binary]: `./bin/${input.binary}.exe` },
scripts: { postinstall: "node ./postinstall.mjs" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
+4
View File
@@ -114,6 +114,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
],
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
@@ -1,4 +1,4 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
@@ -75,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
: Effect.logInfo(message, tags)
runFork(effect)
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
}),
)
+13 -2
View File
@@ -1,7 +1,9 @@
import { Effect, Option } from "effect"
import { Context, Effect, FileSystem, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
@@ -9,9 +11,17 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
const config = yield* Config.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const service = server.service
yield* Effect.promise(() =>
runMini({
server,
server: {
endpoint: server.endpoint,
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
},
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
@@ -21,6 +31,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
}),
)
}),
@@ -0,0 +1,24 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
}),
)
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
const separator = process.argv.indexOf("--", 2)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
+4 -2
View File
@@ -7,7 +7,6 @@ import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
@@ -32,6 +31,9 @@ const Handlers = Runtime.handlers(Commands, {
auth: () => import("./commands/handlers/mcp/auth"),
logout: () => import("./commands/handlers/mcp/logout"),
},
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
@@ -58,7 +60,7 @@ Effect.logInfo("cli starting", {
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(Observability.layer),
Effect.provide(NodeServices.layer),
Effect.scoped,
+176
View File
@@ -0,0 +1,176 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
export type InteractiveStdin = {
stdin: NodeJS.ReadStream
cleanup(): void
}
type MiniHost = MiniFrontendInput["host"]
function preferences(statePath: string): MiniHost["preferences"] {
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
return {
async resolveVariant(model) {
if (!model) return
return repository.resolveVariant(model)
},
async saveVariant(model, variant) {
if (!model) return
await repository.saveVariant(model, variant).catch(() => undefined)
},
}
}
function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
return {
subscribe(listener) {
let subscribed = true
process.on(name, listener)
return () => {
if (!subscribed) return
subscribed = false
process.off(name, listener)
}
},
}
}
function createTrace(
logPath: string,
diagnostics: { pid: number; cwd: string; argv: string[] },
): MiniHost["diagnostics"]["trace"] {
if (!process.env.OPENCODE_DIRECT_TRACE) return
const stamp = new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "Z")
const target = path.join(logPath, "direct", `${stamp}-${diagnostics.pid}.jsonl`)
const text = (data: unknown) =>
JSON.stringify(data, (_key, value) => (typeof value === "bigint" ? String(value) : value), 0)
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(
path.join(logPath, "direct", "latest.json"),
text({
time: new Date().toISOString(),
...diagnostics,
path: target,
}) + "\n",
)
const trace = {
write(type: string, data?: unknown) {
fs.appendFileSync(
target,
text({
time: new Date().toISOString(),
pid: diagnostics.pid,
type,
data,
}) + "\n",
)
},
}
trace.write("trace.start", {
argv: diagnostics.argv,
cwd: diagnostics.cwd,
path: target,
})
return trace
}
function openTerminalStdin(target: string): NodeJS.ReadStream {
return new ReadStream(fs.openSync(target, "r"))
}
export function resolveInteractiveStdin(
stdin: NodeJS.ReadStream = process.stdin,
open: (target: string) => NodeJS.ReadStream = openTerminalStdin,
platform: NodeJS.Platform = process.platform,
): InteractiveStdin {
if (stdin.isTTY) return { stdin, cleanup() {} }
const target = platform === "win32" ? "CONIN$" : "/dev/tty"
try {
const source = open(target)
let cleaned = false
return {
stdin: source,
cleanup() {
if (cleaned) return
cleaned = true
source.destroy()
},
}
} catch (error) {
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
}
}
/** @internal Exported for owner-local resource cleanup tests. */
export async function usingInteractiveStdin<T>(
run: (terminal: InteractiveStdin) => Promise<T>,
resolve: () => InteractiveStdin = resolveInteractiveStdin,
) {
const terminal = resolve()
try {
return await run(terminal)
} finally {
terminal.cleanup()
}
}
/** @internal Exported for owner-local host capability tests. */
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: { home: string; state: string; log: string }
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
state: Global.Path.state,
log: Global.Path.log,
}
const diagnostics = {
pid: process.pid,
cwd: input.directory,
argv: process.argv.slice(2),
}
return {
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
write(value) {
process.stdout.write(value)
},
},
files: {
readText: (url) => readFile(new URL(url), "utf8"),
},
editor: {
async open(options) {
const { openEditor } = await import("@opencode-ai/tui/editor")
return openEditor(options)
},
},
paths: { home: paths.home },
signals: {
sigint: signal("SIGINT"),
sigusr2: signal("SIGUSR2"),
},
startup: {
showTiming: Flag.OPENCODE_SHOW_TTFD,
now: () => performance.now(),
},
diagnostics: {
trace: createTrace(paths.log, diagnostics),
},
preferences: preferences(paths.state),
}
}
+277
View File
@@ -0,0 +1,277 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
export type MiniCommandInput = {
server: {
endpoint: Endpoint
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
}
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
prompt?: string
replay?: boolean
replayLimit?: number
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
}
type Model = MiniFrontendInput["model"]
class MiniInputError extends Error {}
export async function runMini(input: MiniCommandInput) {
try {
validate(input)
const result = await usingInteractiveStdin(async (terminal) => {
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const frontendTask = import("@opencode-ai/tui/mini")
const directory = localDirectory()
const connection = createMiniConnection(input.server)
const sdk = connection.sdk
const requested = parseModel(input.model)
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
const prepare = prepareTarget(input.agent)
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
const resolved = await resolveMiniTarget({
sdk: initial,
reconnect: connection.reconnect,
signal,
resolve: (client) =>
resolveSessionTarget({
client,
location: { directory },
continue: input.continue,
session: input.session,
fork: input.fork,
model: requested,
agent: input.agent,
prepare,
signal,
}).catch((error) => {
if (error instanceof Error && error.message === "Session not found")
throw new MiniInputError(error.message)
throw error
}),
})
const target = resolved.value
return {
sdk: resolved.sdk,
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: target.resume,
}
}
const create = (
client: OpenCodeClient,
next: {
location: { directory: string; workspaceID?: string }
agent: string | undefined
model: Model
variant: string | undefined
},
signal?: AbortSignal,
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
: undefined,
prepare,
signal,
}).then((target) => ({
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: false,
}))
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }),
sdk,
directory,
target: resolveTarget,
reconnect: connection.reconnect,
createSession: create,
agent: input.agent,
model,
variant: requested?.variant,
files: [],
initialInput,
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
})
})
if (result.exitCode !== 0) process.exit(result.exitCode)
} catch (error) {
if (error instanceof MiniInputError || (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR))
fail(error.message)
throw error
}
}
/** @internal Exported for CLI boundary tests. */
export function createMiniConnection(input: MiniCommandInput["server"]) {
const make = (endpoint: Endpoint) =>
OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const reconnect = input.reconnect
return {
sdk: make(input.endpoint),
reconnect: reconnect
? async (signal: AbortSignal) => {
const endpoint = await reconnect(signal)
return make(endpoint)
}
: undefined,
}
}
/** @internal Exported for reconnect lifecycle tests. */
export async function resolveMiniTarget<A>(input: {
sdk: OpenCodeClient
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
signal: AbortSignal
resolve: (sdk: OpenCodeClient) => Promise<A>
}) {
let sdk = input.sdk
while (true) {
try {
return { sdk, value: await input.resolve(sdk) }
} catch (error) {
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
while (true) {
try {
sdk = await input.reconnect(input.signal)
break
} catch (resolveError) {
if (input.signal.aborted) throw resolveError
await setTimeout(250, undefined, { signal: input.signal })
}
}
}
}
}
export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
}
/** @internal Exported for testing. */
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
if (!prompt) return piped || undefined
if (!piped) return prompt
return piped + "\n" + prompt
}
function validate(input: MiniCommandInput) {
validateMiniTerminal()
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
fail("--replay-limit must be a positive integer")
}
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
}
function localDirectory(): string {
const root = process.env.PWD ?? process.cwd()
try {
process.chdir(root)
return process.cwd()
} catch {
throw new MiniInputError(`Failed to change directory to ${root}`)
}
}
function parseModel(value?: string) {
try {
return parseSessionTargetModel(value)
} catch {
throw new MiniInputError("--model must use the format provider/model[#variant]")
}
}
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
return async (input) => {
if (input.model)
await waitForCatalogReady({
sdk: input.client,
directory: input.location.directory,
workspace: input.location.workspaceID,
model: { providerID: input.model.providerID, modelID: input.model.id },
signal: input.signal,
})
return {
model: input.model,
agent: requestedAgent
? await validateAgent(
input.client,
input.location.directory,
input.location.workspaceID,
requestedAgent,
input.signal,
)
: input.agent,
}
}
}
async function validateAgent(
sdk: OpenCodeClient,
directory: string,
workspace: string | undefined,
name?: string,
signal?: AbortSignal,
) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline && !signal?.aborted) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25, undefined, { signal }).catch(() => {})
}
if (signal?.aborted) return
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
}
warning(`agent "${name}" not found. Falling back to default agent`)
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}
function fail(message: string): never {
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
process.exit(1)
}
-159
View File
@@ -1,159 +0,0 @@
import type {
AgentListOutput,
CommandListOutput,
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
SkillListOutput,
} from "@opencode-ai/client/promise"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = AgentListOutput["data"][number]
type CurrentCommand = CommandListOutput["data"][number]
type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = ProviderListOutput["data"][number]
type CurrentModel = ModelListOutput["data"][number]
function location(directory: string, workspace?: string) {
return {
location: {
directory,
workspace,
},
}
}
function defaultCost(model: CurrentModel) {
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
if (!picked) {
return undefined
}
return {
...picked,
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
}
}
export function runAgent(input: CurrentAgent): RunAgent {
return {
id: input.id,
name: input.name,
description: input.description,
mode: input.mode,
hidden: input.hidden,
}
}
export function runCommand(input: CurrentCommand): RunCommand {
return {
name: input.name,
description: input.description,
}
}
export function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.id,
description: input.description,
source: "skill",
}
}
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
const grouped = new Map<string, RunProvider>()
for (const provider of providers) {
grouped.set(provider.id, {
id: provider.id,
name: provider.name,
models: {},
})
}
for (const model of models) {
const provider = grouped.get(model.providerID) ?? {
id: model.providerID,
name: model.providerID,
models: {},
}
provider.models[model.id] = {
id: model.id,
providerID: model.providerID,
name: model.name,
capabilities: model.capabilities,
cost: defaultCost(model),
limit: model.limit,
status: model.status,
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
}
grouped.set(provider.id, provider)
}
return [...grouped.values()]
}
// A location boots its plugins in a deferred background batch after the layer
// is built, so first-turn model resolution can observe empty catalog state.
// For explicit --model flows, wait for that exact ref to appear before prompt
// admission. On timeout, return and let the real execution error surface.
export async function waitForCatalogReady(input: {
sdk: OpenCodeClient
directory: string
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) {
const models = await input.sdk.model
.list(location(input.directory, input.workspace))
.then((result) => result.data)
.catch(() => undefined)
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
export async function waitForDefaultModel(input: {
sdk: OpenCodeClient
directory: string
timeoutMs?: number
active?: () => boolean
}): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && (input.active?.() ?? true)) {
const model = await input.sdk.model
.default(location(input.directory))
.then((result) => result.data)
.catch(() => undefined)
if (model) return { providerID: model.providerID, modelID: model.id }
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.agent.list(location(directory))
return result.data.map(runAgent)
}
export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([
sdk.command.list(location(directory)),
sdk.skill.list(location(directory)),
])
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
}
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.reference.list(location(directory))
return result.data.filter((reference) => !reference.hidden)
}
export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
const [providers, models] = await Promise.all([
sdk.provider.list(location(directory)),
sdk.model.list(location(directory)),
])
return runProviders([...providers.data], [...models.data])
}
-573
View File
@@ -1,573 +0,0 @@
// Question UI body for the direct-mode footer.
//
// Renders inside the footer when the reducer pushes a FooterView of type
// "question". Supports single-question and multi-question flows:
//
// Single question: options list with up/down selection, digit shortcuts,
// and optional custom text input.
//
// Multi-question: tabbed interface where each question is a tab, plus a
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
// or left/right to navigate between questions.
//
// All state logic lives in question.shared.ts as a pure state machine.
// This component just renders it and dispatches keyboard events.
/** @jsxImportSource @opentui/solid */
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import type { QuestionV2Request } from "@opencode-ai/client/promise"
import {
createQuestionBodyState,
questionConfirm,
questionCustom,
questionInfo,
questionInput,
questionMove,
questionOther,
questionPicked,
questionReject,
questionSave,
questionSelect,
questionSetEditing,
questionSetSelected,
questionSetSubmitting,
questionSetTab,
questionSingle,
questionStoreCustom,
questionSubmit,
questionSync,
questionTabs,
questionTotal,
} from "./question.shared"
import { footerWidthPolicy } from "./footer.width"
import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types"
export function RunQuestionBody(props: {
request: QuestionV2Request
theme: RunFooterTheme
onReply: (input: QuestionReply) => void | Promise<void>
onReject: (input: QuestionReject) => void | Promise<void>
}) {
const dims = useTerminalDimensions()
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
const single = createMemo(() => questionSingle(props.request))
const confirm = createMemo(() => questionConfirm(props.request, state()))
const info = createMemo(() => questionInfo(props.request, state()))
const input = createMemo(() => questionInput(state()))
const other = createMemo(() => questionOther(props.request, state()))
const picked = createMemo(() => questionPicked(state()))
const disabled = createMemo(() => state().submitting)
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const verb = createMemo(() => {
if (confirm()) {
return "submit"
}
if (info()?.multiple) {
return "toggle"
}
if (single()) {
return "submit"
}
return "confirm"
})
let area: TextareaRenderable | undefined
createEffect(() => {
setState((prev) => questionSync(prev, props.request.id))
})
const setTab = (tab: number) => {
setState((prev) => questionSetTab(prev, tab))
}
const move = (dir: -1 | 1) => {
setState((prev) => questionMove(prev, props.request, dir))
}
const beginReply = async (input: QuestionReply) => {
setState((prev) => questionSetSubmitting(prev, true))
try {
await props.onReply(input)
} catch {
setState((prev) => questionSetSubmitting(prev, false))
}
}
const beginReject = async (input: QuestionReject) => {
setState((prev) => questionSetSubmitting(prev, true))
try {
await props.onReject(input)
} catch {
setState((prev) => questionSetSubmitting(prev, false))
}
}
const saveCustom = () => {
const cur = state()
const next = questionSave(cur, props.request)
if (next.state !== cur) {
setState(next.state)
}
if (!next.reply) {
return
}
void beginReply(next.reply)
}
const choose = (selected: number) => {
const base = state()
const cur = questionSetSelected(base, selected)
const next = questionSelect(cur, props.request)
if (next.state !== base) {
setState(next.state)
}
if (!next.reply) {
return
}
void beginReply(next.reply)
}
const mark = (selected: number) => {
setState((prev) => questionSetSelected(prev, selected))
}
const select = () => {
const cur = state()
const next = questionSelect(cur, props.request)
if (next.state !== cur) {
setState(next.state)
}
if (!next.reply) {
return
}
void beginReply(next.reply)
}
const submit = () => {
void beginReply(questionSubmit(props.request, state()))
}
const reject = () => {
void beginReject(questionReject(props.request))
}
useKeyboard((event) => {
const cur = state()
if (cur.submitting) {
event.preventDefault()
return
}
if (cur.editing) {
if (event.name === "escape") {
setState((prev) => questionSetEditing(prev, false))
event.preventDefault()
return
}
return
}
if (!single() && (event.name === "left" || event.name === "h")) {
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
event.preventDefault()
return
}
if (!single() && (event.name === "right" || event.name === "l")) {
setTab((cur.tab + 1) % questionTabs(props.request))
event.preventDefault()
return
}
if (!single() && event.name === "tab") {
const dir = event.shift ? -1 : 1
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
event.preventDefault()
return
}
if (questionConfirm(props.request, cur)) {
if (event.name === "return") {
submit()
event.preventDefault()
return
}
if (event.name === "escape") {
reject()
event.preventDefault()
}
return
}
const total = questionTotal(props.request, cur)
const max = Math.min(total, 9)
const digit = Number(event.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
choose(digit - 1)
event.preventDefault()
return
}
if (event.name === "up" || event.name === "k") {
move(-1)
event.preventDefault()
return
}
if (event.name === "down" || event.name === "j") {
move(1)
event.preventDefault()
return
}
if (event.name === "return") {
select()
event.preventDefault()
return
}
if (event.name === "escape") {
reject()
event.preventDefault()
}
})
createEffect(() => {
if (!state().editing || !area || area.isDestroyed) {
return
}
if (area.plainText !== input()) {
area.setText(input())
area.cursorOffset = input().length
}
queueMicrotask(() => {
if (!area || area.isDestroyed || !state().editing) {
return
}
area.focus()
area.cursorOffset = area.plainText.length
})
})
return (
<box width="100%" height="100%" flexDirection="column">
<box
flexDirection="column"
gap={1}
paddingLeft={1}
paddingRight={3}
paddingTop={1}
flexGrow={1}
flexShrink={1}
backgroundColor={props.theme.surface}
>
<Show when={!single()}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<For each={props.request.questions}>
{(item, index) => {
const active = () => state().tab === index()
const answered = () => (state().answers[index()]?.length ?? 0) > 0
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
onMouseUp={() => {
if (!disabled()) setTab(index())
}}
>
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
{item.header}
</text>
</box>
)
}}
</For>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
onMouseUp={() => {
if (!disabled()) setTab(props.request.questions.length)
}}
>
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
</box>
</box>
</Show>
<Show
when={!confirm()}
fallback={
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<box width="100%" flexDirection="column" gap={1}>
<box paddingLeft={1}>
<text fg={props.theme.text}>Review</text>
</box>
<For each={props.request.questions}>
{(item, index) => {
const value = () => state().answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<box paddingLeft={1}>
<text wrapMode="word">
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
{answered() ? value() : "(not answered)"}
</span>
</text>
</box>
)
}}
</For>
</box>
</scrollbox>
</box>
}
>
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
<box>
<text fg={props.theme.text} wrapMode="word">
{info()?.question}
{info()?.multiple ? " (select all that apply)" : ""}
</text>
</box>
<box flexGrow={1} flexShrink={1}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<box width="100%" flexDirection="column">
<For each={info()?.options ?? []}>
{(item, index) => {
const active = () => state().selected === index()
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
return (
<box
flexDirection="column"
gap={0}
onMouseOver={() => {
if (!disabled()) {
mark(index())
}
}}
onMouseDown={() => {
if (!disabled()) {
mark(index())
}
}}
onMouseUp={() => {
if (!disabled()) {
choose(index())
}
}}
>
<box flexDirection="row">
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
</box>
<box backgroundColor={active() ? props.theme.line : undefined}>
<text
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
>
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
</text>
</box>
<Show when={!info()?.multiple}>
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
</Show>
</box>
<box paddingLeft={3}>
<text fg={props.theme.muted} wrapMode="word">
{item.description}
</text>
</box>
</box>
)
}}
</For>
<Show when={questionCustom(props.request, state())}>
<box
flexDirection="column"
gap={0}
onMouseOver={() => {
if (!disabled()) {
mark(info()?.options.length ?? 0)
}
}}
onMouseDown={() => {
if (!disabled()) {
mark(info()?.options.length ?? 0)
}
}}
onMouseUp={() => {
if (!disabled()) {
choose(info()?.options.length ?? 0)
}
}}
>
<box flexDirection="row">
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
<text
fg={other() ? props.theme.highlight : props.theme.muted}
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
</box>
<box backgroundColor={other() ? props.theme.line : undefined}>
<text
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
>
{info()?.multiple
? `[${picked() ? "✓" : " "}] Type your own answer`
: "Type your own answer"}
</text>
</box>
<Show when={!info()?.multiple}>
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<Show
when={state().editing}
fallback={
<Show when={input()}>
<box paddingLeft={3}>
<text fg={props.theme.muted} wrapMode="word">
{input()}
</text>
</box>
</Show>
}
>
<box paddingLeft={3}>
<textarea
width="100%"
minHeight={1}
maxHeight={4}
wrapMode="word"
placeholder="Type your own answer"
placeholderColor={props.theme.muted}
textColor={props.theme.text}
focusedTextColor={props.theme.text}
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
focused={!disabled()}
onSubmit={saveCustom}
onContentChange={() => {
if (!area || area.isDestroyed || disabled()) {
return
}
const text = area.plainText
setState((prev) => questionStoreCustom(prev, prev.tab, text))
}}
ref={(item) => {
area = item
}}
/>
</box>
</Show>
</box>
</Show>
</box>
</scrollbox>
</box>
</box>
</Show>
</box>
<box
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
gap={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
<Show
when={!disabled()}
fallback={
<text fg={props.theme.muted} wrapMode="word">
Waiting for question event...
</text>
}
>
<box
flexDirection={narrow() ? "column" : "row"}
gap={narrow() ? 1 : 2}
flexShrink={0}
width={narrow() ? "100%" : undefined}
>
<Show
when={!state().editing}
fallback={
<>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>save</span>
</text>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>cancel</span>
</text>
</>
}
>
<Show when={!single()}>
<text fg={props.theme.text}>
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
</text>
</Show>
<Show when={!confirm()}>
<text fg={props.theme.text}>
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
</text>
</Show>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
</text>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
</text>
</Show>
</box>
</Show>
</box>
</box>
)
}
-8
View File
@@ -1,8 +0,0 @@
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
export {
runNonInteractive,
mergeInput as mergeNonInteractiveInput,
pickRunModel,
parseRunModel,
type RunCommandInput,
} from "./run"
-175
View File
@@ -1,175 +0,0 @@
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
import { readStdin } from "../util/io"
import { setTimeout } from "node:timers/promises"
export type MiniCommandInput = {
server: ServerConnection.Resolved
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
prompt?: string
replay?: boolean
replayLimit?: number
demo?: boolean
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
export async function runMini(input: MiniCommandInput) {
validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const runtimeTask = import("./runtime")
const directory = localDirectory()
try {
const sdk = OpenCode.make({
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
})
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, directory, input.agent)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
const session = selected ?? (await createSession(sdk, directory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
}
const create = (
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
) => createSession(sdk, directory, next.agent, next.model, next.variant)
const runtime = await runtimeTask
await runtime.runInteractiveDeferredMode({
sdk,
directory,
resolveAgent,
session: resolveSession,
createSession: create,
agent: input.agent,
model,
variant: undefined,
files: [],
initialInput,
thinking: true,
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
})
} catch (error) {
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
throw error
}
}
export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
}
/** @internal Exported for testing. */
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
if (!prompt) return piped || undefined
if (!piped) return prompt
return piped + "\n" + prompt
}
function validate(input: MiniCommandInput) {
validateMiniTerminal()
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
fail("--replay-limit must be a positive integer")
}
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
resolveInteractiveStdin().cleanup?.()
}
function localDirectory(): string {
const root = process.env.PWD ?? process.cwd()
try {
process.chdir(root)
return process.cwd()
} catch {
fail(`Failed to change directory to ${root}`)
}
}
function parseModel(value?: string): RunInput["model"] {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) fail("--model must use the format provider/model")
return { providerID, modelID }
}
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25)
}
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
}
warning(`agent "${name}" not found. Falling back to default agent`)
}
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
const selected =
preselected ??
(input.session
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await sdk.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined)
if (input.session && !selected) fail("Session not found")
if (!selected) return
if (!input.fork) return selected
return sdk.session.fork({ sessionID: selected.id })
}
async function createSession(
sdk: OpenCodeClient,
directory: string,
agent: string | undefined,
model: RunInput["model"],
variant?: string,
): Promise<Session> {
if (model) await waitForCatalogReady({ sdk, directory, model })
return sdk.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory },
})
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}
function fail(message: string): never {
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
process.exit(1)
}
-260
View File
@@ -1,260 +0,0 @@
// Pure state machine for the permission UI.
//
// Lives outside the JSX component so it can be tested independently. The
// machine has three stages:
//
// permission → initial view with Allow once / Always / Reject options
// always → confirmation step (Confirm / Cancel)
// reject → text input for rejection message
//
// permissionRun() is the main transition: given the current state and the
// selected option, it returns a new state and optionally a PermissionReply
// to send to the SDK. The component calls this on enter/click.
//
// permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting.
import type { PermissionV2Request } from "@opencode-ai/client/promise"
import type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool"
type Dict = Record<string, unknown>
export type PermissionStage = "permission" | "always" | "reject"
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
export type PermissionBodyState = {
requestID: string
stage: PermissionStage
selected: PermissionOption
message: string
submitting: boolean
}
export type PermissionInfo = {
icon: string
title: string
lines: string[]
diff?: string
file?: string
}
export type PermissionStep = {
state: PermissionBodyState
reply?: PermissionReply
}
function dict(v: unknown): Dict {
if (!v || typeof v !== "object" || Array.isArray(v)) {
return {}
}
return { ...v }
}
function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
function data(request: PermissionV2Request): Dict {
const meta = dict(request.metadata)
return {
...meta,
...dict(meta.input),
}
}
function patterns(request: PermissionV2Request): string[] {
return request.resources.filter((item): item is string => typeof item === "string")
}
export function createPermissionBodyState(requestID: string): PermissionBodyState {
return {
requestID,
stage: "permission",
selected: "once",
message: "",
submitting: false,
}
}
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
if (stage === "permission") {
return ["once", "always", "reject"]
}
if (stage === "always") {
return ["confirm", "cancel"]
}
return []
}
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
const pats = patterns(request)
const input = data(request)
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
if (info) {
return info
}
if (request.action === "external_directory") {
const meta = dict(request.metadata)
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
return {
icon: "←",
title: `Access external directory ${toolPath(dir, { home: true })}`,
lines: pats.map((item) => `- ${item}`),
}
}
if (request.action === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
lines: ["This keeps the session running despite repeated failures."],
}
}
return {
icon: "⚙",
title: `Call tool ${request.action}`,
lines: [`Tool: ${request.action}`],
}
}
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
const save = request.save ?? []
if (save.length === 1 && save[0] === "*") {
return [`This will allow ${request.action} until OpenCode is restarted.`]
}
return [
"This will allow the following patterns until OpenCode is restarted.",
...save.map((item) => `- ${item}`),
]
}
export function permissionLabel(option: PermissionOption): string {
if (option === "once") return "Allow once"
if (option === "always") return "Allow always"
if (option === "reject") return "Reject"
if (option === "confirm") return "Confirm"
return "Cancel"
}
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
return {
requestID,
reply,
...(message && message.trim() ? { message: message.trim() } : {}),
}
}
export function permissionShift(
state: PermissionBodyState,
dir: -1 | 1,
list = permissionOptions(state.stage),
): PermissionBodyState {
if (list.length === 0) {
return state
}
const idx = Math.max(0, list.indexOf(state.selected))
const selected = list[(idx + dir + list.length) % list.length]
return {
...state,
selected,
}
}
export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
return {
...state,
selected: option,
}
}
export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
if (state.submitting) {
return { state }
}
if (state.stage === "permission") {
if (option === "always") {
return {
state: {
...state,
stage: "always",
selected: "confirm",
},
}
}
if (option === "reject") {
return {
state: {
...state,
stage: "reject",
selected: "reject",
},
}
}
return {
state,
reply: permissionReply(requestID, "once"),
}
}
if (state.stage !== "always") {
return { state }
}
if (option === "cancel") {
return {
state: {
...state,
stage: "permission",
selected: "always",
},
}
}
return {
state,
reply: permissionReply(requestID, "always"),
}
}
export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
if (state.submitting) {
return undefined
}
return permissionReply(requestID, "reject", state.message)
}
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
return {
...state,
stage: "permission",
selected: "reject",
}
}
export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
if (state.stage === "always") {
return {
...state,
stage: "permission",
selected: "always",
}
}
return {
...state,
stage: "reject",
selected: "reject",
}
}
-157
View File
@@ -1,157 +0,0 @@
import type { RunPromptPart } from "./types"
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
export function resolveEditorSlashValue(text: string) {
const head = slashHead(text)
if (!head || head.name.toLowerCase() !== "editor") {
return text
}
return head.arguments
}
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
const matches = new Map<number, Mention | undefined>()
const used: Array<{ start: number; end: number }> = []
for (const [index, part] of parts.entries()) {
if (part.type !== "file" && part.type !== "agent") {
continue
}
const text = promptPartText(part)
if (!text) {
continue
}
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
if (start === -1) {
matches.set(index, undefined)
continue
}
const end = start + text.length
used.push({ start, end })
matches.set(index, updatePromptPart(part, start, end, text))
}
const next: RunPromptPart[] = []
for (const [index, part] of parts.entries()) {
if (part.type !== "file" && part.type !== "agent") {
next.push(part)
continue
}
if (!promptPartText(part)) {
next.push(part)
continue
}
const match = matches.get(index)
if (match) {
next.push(match)
}
}
return next
}
function slashHead(text: string) {
if (!text.startsWith("/")) {
return
}
for (let i = 1; i < text.length; i++) {
switch (text[i]) {
case " ":
case "\t":
case "\n":
return {
name: text.slice(1, i),
arguments: text.slice(i + 1),
}
}
}
return {
name: text.slice(1),
arguments: "",
}
}
function promptPartText(part: Mention) {
if (part.type === "agent") {
return part.source?.value
}
return part.source?.text.value
}
function promptPartStart(part: Mention) {
if (part.type === "agent") {
return part.source?.start ?? Number.POSITIVE_INFINITY
}
return part.source?.text.start ?? Number.POSITIVE_INFINITY
}
function findPromptPartIndex(content: string, text: string, used: Array<{ start: number; end: number }>, hint: number) {
let searchFrom = 0
let best = -1
let distance = Number.POSITIVE_INFINITY
const hinted = Number.isFinite(hint)
while (true) {
const start = content.indexOf(text, searchFrom)
if (start === -1) {
return best
}
const end = start + text.length
searchFrom = start + 1
if (used.some((range) => start < range.end && end > range.start)) {
continue
}
if (!hinted) {
return start
}
const nextDistance = Math.abs(start - hint)
if (nextDistance < distance) {
best = start
distance = nextDistance
}
}
}
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
if (part.type === "agent") {
return {
...part,
source: {
start,
end,
value: text,
},
}
}
if (!part.source?.text) {
return part
}
return {
...part,
source: {
...part.source,
text: {
...part.source.text,
start,
end,
value: text,
},
},
}
}
-340
View File
@@ -1,340 +0,0 @@
// Pure state machine for the question UI.
//
// Supports both single-question and multi-question flows. Single questions
// submit immediately on selection. Multi-question flows use tabs and a
// final confirmation step.
//
// State transitions:
// questionSelect → picks an option (single: submits, multi: toggles/advances)
// questionSave → saves custom text input
// questionMove → arrow key navigation through options
// questionSetTab → tab navigation between questions
// questionSubmit → builds the final QuestionReply with all answers
//
// Custom answers: if a question has custom=true, an extra "Type your own
// answer" option appears. Selecting it enters editing mode with a text field.
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
import type { QuestionReject, QuestionReply } from "./types"
export type QuestionBodyState = {
requestID: string
tab: number
answers: string[][]
custom: string[]
selected: number
editing: boolean
submitting: boolean
}
export type QuestionStep = {
state: QuestionBodyState
reply?: QuestionReply
}
export function createQuestionBodyState(requestID: string): QuestionBodyState {
return {
requestID,
tab: 0,
answers: [],
custom: [],
selected: 0,
editing: false,
submitting: false,
}
}
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
if (state.requestID === requestID) {
return state
}
return createQuestionBodyState(requestID)
}
export function questionSingle(request: QuestionV2Request): boolean {
return request.questions.length === 1 && request.questions[0]?.multiple !== true
}
export function questionTabs(request: QuestionV2Request): number {
return questionSingle(request) ? 1 : request.questions.length + 1
}
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
return !questionSingle(request) && state.tab === request.questions.length
}
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
return request.questions[state.tab]
}
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
return questionInfo(request, state)?.custom !== false
}
export function questionInput(state: QuestionBodyState): string {
return state.custom[state.tab] ?? ""
}
export function questionPicked(state: QuestionBodyState): boolean {
const value = questionInput(state)
if (!value) {
return false
}
return state.answers[state.tab]?.includes(value) ?? false
}
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
const info = questionInfo(request, state)
if (!info || info.custom === false) {
return false
}
return state.selected === info.options.length
}
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
const info = questionInfo(request, state)
if (!info) {
return 0
}
return info.options.length + (questionCustom(request, state) ? 1 : 0)
}
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
}
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
return {
...state,
tab,
selected: 0,
editing: false,
}
}
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
return {
...state,
selected,
}
}
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
return {
...state,
editing,
}
}
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
return {
...state,
submitting,
}
}
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
const answers = [...state.answers]
answers[tab] = list
return {
...state,
answers,
}
}
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
const custom = [...state.custom]
custom[tab] = text
return {
...state,
custom,
}
}
function questionPick(
state: QuestionBodyState,
request: QuestionV2Request,
answer: string,
custom = false,
): QuestionStep {
const answers = [...state.answers]
answers[state.tab] = [answer]
let next: QuestionBodyState = {
...state,
answers,
editing: false,
}
if (custom) {
const list = [...state.custom]
list[state.tab] = answer
next = {
...next,
custom: list,
}
}
if (questionSingle(request)) {
return {
state: next,
reply: {
requestID: request.id,
answers: [[answer]],
},
}
}
return {
state: questionSetTab(next, state.tab + 1),
}
}
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
const list = [...(state.answers[state.tab] ?? [])]
const idx = list.indexOf(answer)
if (idx === -1) {
list.push(answer)
} else {
list.splice(idx, 1)
}
return storeAnswers(state, state.tab, list)
}
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
const total = questionTotal(request, state)
if (total === 0) {
return state
}
return {
...state,
selected: (state.selected + dir + total) % total,
}
}
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state)
if (!info) {
return { state }
}
if (questionOther(request, state)) {
if (!info.multiple) {
return {
state: questionSetEditing(state, true),
}
}
const value = questionInput(state)
if (value && questionPicked(state)) {
return {
state: questionToggle(state, value),
}
}
return {
state: questionSetEditing(state, true),
}
}
const option = info.options[state.selected]
if (!option) {
return { state }
}
if (info.multiple) {
return {
state: questionToggle(state, option.label),
}
}
return questionPick(state, request, option.label)
}
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
const info = questionInfo(request, state)
if (!info) {
return { state }
}
const value = questionInput(state).trim()
const prev = state.custom[state.tab]
if (!value) {
if (!prev) {
return {
state: questionSetEditing(state, false),
}
}
const next = questionStoreCustom(state, state.tab, "")
return {
state: questionSetEditing(
storeAnswers(
next,
state.tab,
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
),
false,
),
}
}
if (info.multiple) {
const answers = [...(state.answers[state.tab] ?? [])]
if (prev) {
const idx = answers.indexOf(prev)
if (idx !== -1) {
answers.splice(idx, 1)
}
}
if (!answers.includes(value)) {
answers.push(value)
}
const next = questionStoreCustom(state, state.tab, value)
return {
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
}
}
return questionPick(state, request, value, true)
}
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
return {
requestID: request.id,
answers: questionAnswers(state, request.questions.length),
}
}
export function questionReject(request: QuestionV2Request): QuestionReject {
return {
requestID: request.id,
}
}
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
if (state.submitting) {
return "Waiting for question event..."
}
if (questionConfirm(request, state)) {
return "enter submit esc dismiss"
}
if (state.editing) {
return "enter save esc cancel"
}
const info = questionInfo(request, state)
if (questionSingle(request)) {
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
}
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
}
-166
View File
@@ -1,166 +0,0 @@
// Boot-time resolution for direct interactive mode.
//
// These functions run concurrently at startup to gather everything the runtime
// needs before the first frame: TUI keymap config, diff display style,
// model variant list with context limits, and session history for the prompt
// history ring. All are async because they read config or hit the SDK, but
// none block each other.
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config/v1"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { loadRunProviders } from "./catalog.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
export type ModelInfo = {
providers: RunProvider[]
variants: string[]
limits: Record<string, number>
}
export type SessionInfo = {
first: boolean
history: RunPrompt[]
model?: NonNullable<RunInput["model"]>
variant: string | undefined
}
type BootService = {
readonly resolveModelInfo: (
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
) => Effect.Effect<ModelInfo>
readonly resolveSessionInfo: (
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
) => Effect.Effect<SessionInfo>
}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
function emptyModelInfo(): ModelInfo {
return {
providers: [],
variants: [],
limits: {},
}
}
function emptySessionInfo(): SessionInfo {
return {
first: true,
history: [],
variant: undefined,
}
}
function defaultRunTuiConfig(): RunTuiConfig {
return {
...resolve({}, { terminalSuspend: process.platform !== "win32" }),
diff_style: "auto",
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
) {
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
const limits = Object.fromEntries(
providers.flatMap((provider) =>
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
const limit = info?.limit?.context
if (typeof limit !== "number" || limit <= 0) {
return []
}
return [[`${provider.id}/${modelID}`, limit] as const]
}),
),
)
if (!model) {
return {
providers,
variants: [],
limits,
}
}
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
return {
providers,
variants: Object.keys(info?.variants ?? {}),
limits,
}
})
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
) {
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
if (!session) {
return emptySessionInfo()
}
return {
first: session.first,
history: sessionHistory(session),
model: session.model,
variant: pickVariant(model ?? session.model, session),
}
})
return Service.of({
resolveModelInfo,
resolveSessionInfo,
})
}),
)
const node = makeGlobalNode({ service: Service, layer, deps: [] })
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
sdk: RunInput["sdk"],
directory: string,
model: RunInput["model"],
): Promise<ModelInfo> {
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
}
export function resolveModelInfoStrict(sdk: RunInput["sdk"], directory: string, model: RunInput["model"]) {
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model))
}
// Fetches session messages to determine if this is the first turn and build prompt history.
export async function resolveSessionInfo(
sdk: RunInput["sdk"],
sessionID: string,
model: RunInput["model"],
): Promise<SessionInfo> {
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
}
// Reads TUI config once for direct mode keymap setup and display preferences.
export async function resolveRunTuiConfig(
config?: RunTuiConfig | Promise<RunTuiConfig>,
): Promise<RunTuiConfig> {
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
}
export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
}
-389
View File
@@ -1,389 +0,0 @@
// Lifecycle management for the split-footer renderer.
//
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
// from the terminal palette, writes the entry splash to scrollback, and
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
// the exit splash and tears everything down in the right order:
// footer.close → footer.destroy → renderer shutdown.
//
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { Global } from "@opencode-ai/core/global"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
import type {
FooterApi,
PermissionReply,
QuestionReject,
QuestionReply,
RunAgent,
RunInput,
RunPrompt,
RunReference,
RunTuiConfig,
} from "./types"
import { formatModelLabel } from "./variant.shared"
const FOOTER_HEIGHT = 4
type SplashState = {
entry: boolean
exit: boolean
}
type CycleResult = {
modelLabel?: string
status?: string
variant?: string | undefined
variants?: string[]
}
type FooterLabels = {
agentLabel: string
modelLabel: string
}
export type LifecycleInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
references: RunReference[]
sessionID: string
sessionTitle?: string
getSessionID?: () => string | undefined
first: boolean
history: RunPrompt[]
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
onQuestionReject: (input: QuestionReject) => void | Promise<void>
onCycleVariant?: () => CycleResult | void
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
export type Lifecycle = {
footer: FooterApi
onResize(fn: () => void): () => void
refreshTheme(): void
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
// Gracefully tears down the renderer. Order matters: switch external output
// back to passthrough before leaving split-footer mode, so pending stdout
// doesn't get captured into the now-dead scrollback pipeline.
function shutdown(renderer: CliRenderer): void {
if (renderer.isDestroyed) {
return
}
if (renderer.externalOutputMode === "capture-stdout") {
renderer.externalOutputMode = "passthrough"
}
if (renderer.screenMode === "split-footer") {
renderer.screenMode = "main-screen"
}
if (!renderer.isDestroyed) {
renderer.destroy()
}
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isDefaultTitle(title)) {
return {
title,
showSession: true,
}
}
const next = history.find((item) => item.text.trim().length > 0)
return {
title: next?.text ?? title,
showSession: !!next,
}
}
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
const agentLabel = Locale.titlecase(input.agent ?? "build")
return {
agentLabel,
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
}
}
function directoryLabel(directory: string) {
const resolved = path.resolve(directory)
const display =
resolved === Global.Path.home
? "~"
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
? resolved.replace(Global.Path.home, "~")
: resolved
return display.replaceAll("\\", "/")
}
function queueSplash(
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
state: SplashState,
phase: keyof SplashState,
write: ScrollbackWriter | undefined,
): boolean {
if (state[phase]) {
return false
}
if (!write) {
return false
}
state[phase] = true
renderer.writeToScrollback(write)
renderer.requestRender()
return true
}
// Boots the split-footer renderer and constructs the RunFooter.
//
// The renderer starts in split-footer mode with captured stdout so that
// scrollback commits and footer repaints happen in the same frame. After
// the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin()
const footerTask = import("./footer")
try {
const renderer = await createCliRenderer({
stdin: source.stdin,
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" },
screenMode: "split-footer",
footerHeight: FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
entry: false,
exit: false,
}
const splash = splashInfo(input.sessionTitle, input.history)
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
})
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const wrote = queueSplash(
renderer,
state,
"entry",
entrySplash({
...meta,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory),
}),
)
await renderer.idle().catch(() => {})
const { RunFooter } = await footerTask
let closed = false
let sigintRegistered = false
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
references: input.references,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
model: input.model,
variant: input.variant,
first: input.first,
history: input.history,
theme,
wrote,
tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
onCycleVariant: input.onCycleVariant,
onModelSelect: input.onModelSelect,
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) {
return
}
const { openEditor } = await import("@opencode-ai/tui/editor")
await renderer.idle().catch(() => {})
const ignore = () => {}
detachSigint()
process.on("SIGINT", ignore)
try {
return await openEditor({
value,
cwd: input.directory,
renderer,
stdin: source.stdin,
})
} finally {
process.off("SIGINT", ignore)
attachSigint()
}
},
onSubagentSelect: input.onSubagentSelect,
onSubagentInterrupt: input.onSubagentInterrupt,
})
const sigint = () => {
footer.requestExit()
}
const attachSigint = () => {
if (closed || sigintRegistered) {
return
}
process.on("SIGINT", sigint)
sigintRegistered = true
}
const detachSigint = () => {
if (!sigintRegistered) {
return
}
process.off("SIGINT", sigint)
sigintRegistered = false
}
attachSigint()
const close = async (next: {
showExit: boolean
sessionTitle?: string
sessionID?: string
history?: RunPrompt[]
}) => {
if (closed) {
return
}
closed = true
detachSigint()
let wroteExit = false
try {
await footer.idle().catch(() => {})
const show = renderer.isDestroyed ? false : next.showExit
if (!renderer.isDestroyed && show) {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
wroteExit = queueSplash(
renderer,
state,
"exit",
exitSplash({
...splashMeta({
title: splash.title,
session_id: sessionID,
}),
theme: footer.currentTheme().splash,
}),
)
await renderer.idle().catch(() => {})
}
} finally {
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
}
source.cleanup?.()
}
}
return {
footer,
refreshTheme() {
footer.refreshTheme()
},
onResize(fn) {
let width = renderer.terminalWidth
let height = renderer.terminalHeight
const resize = () => {
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
return
}
width = renderer.terminalWidth
height = renderer.terminalHeight
fn()
}
renderer.on(CliRenderEvents.RESIZE, resize)
return () => renderer.off(CliRenderEvents.RESIZE, resize)
},
async resetForReplay(next) {
if (closed || renderer.isDestroyed || footer.isClosed) {
throw new Error("runtime closed")
}
await footer.idle()
if (closed || renderer.isDestroyed || footer.isClosed) {
throw new Error("runtime closed")
}
footer.resetForReplay(true)
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
renderer.writeToScrollback(
entrySplash({
...splashMeta({
title: splash.title,
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
}),
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.directory),
}),
)
renderer.requestRender()
},
close,
}
} catch (error) {
source.cleanup?.()
throw error
}
}

Some files were not shown because too many files have changed in this diff Show More