Compare commits

..

1 Commits

Author SHA1 Message Date
James Long 04c23b8846 feat(tui): expose native OpenCode theme 2026-07-23 16:35:57 +00:00
1112 changed files with 106322 additions and 78996 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@opencode-ai/client": patch
"@opencode-ai/protocol": patch
"@opencode-ai/cli": patch
---
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot at the top of the session view.
+7
View File
@@ -0,0 +1,7 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
"@opencode-ai/protocol": patch
---
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot above the session composer.
-37
View File
@@ -1,37 +0,0 @@
name: deploy-www
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-www-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: packages/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
- name: Check generated documentation - name: Check generated documentation
if: runner.os == 'Linux' if: runner.os == 'Linux'
working-directory: packages/www working-directory: packages/docs
run: bun run check:generated run: bun run check:generated
e2e: e2e:
@@ -1,68 +0,0 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
+1 -1
View File
@@ -1,3 +1,4 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
@@ -60,7 +61,6 @@ const { a, b } = obj
### Imports ### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`. - Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`. - Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`. - If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading. - Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
+1851 -1875
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true exact = true
# Only install newly resolved package versions published at least 3 days ago. # Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200 minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test] [test]
root = "./do-not-run-tests-from-root" root = "./do-not-run-tests-from-root"
+1 -1
View File
@@ -15,6 +15,6 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1", "@octokit/graphql": "9.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@opencode-ai/sdk": "1.18.5" "@opencode-ai/sdk": "workspace:*"
} }
} }
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=", "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=", "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=", "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA=" "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
} }
} }
+9 -10
View File
@@ -33,12 +33,13 @@
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/stats/*", "packages/stats/*",
"packages/sdk/js",
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.101", "@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.101", "@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.101", "@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
@@ -50,7 +51,6 @@
"@opentui/solid": "0.4.5", "@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4", "@corvu/drawer": "0.2.4",
@@ -69,7 +69,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.98",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
@@ -124,7 +124,7 @@
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5", "@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3", "heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:" "typescript": "catalog:"
}, },
@@ -152,22 +152,21 @@
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:"
"effect": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
} }
} }
+5 -6
View File
@@ -54,7 +54,7 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
A route is the registered, runnable composition of four orthogonal pieces: A route is the registered, runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. - **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`). - **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result. - **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol. - **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
@@ -158,14 +158,13 @@ packages/ai/src/
protocols/ protocols/
shared.ts ProviderShared toolkit used inside protocol impls shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol) openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline openai-responses.ts
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
anthropic-messages.ts anthropic-messages.ts
gemini.ts gemini.ts
bedrock-converse.ts bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...) utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/ providers/
openai-compatible.ts generic Chat helper + family model helpers openai-compatible.ts generic Chat helper + family model helpers
@@ -176,7 +175,7 @@ packages/ai/src/
tool-runtime.ts narrow one-call typed tool dispatcher tool-runtime.ts narrow one-call typed tool dispatcher
``` ```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension. The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
### Shared protocol helpers ### Shared protocol helpers
@@ -241,7 +240,7 @@ const get_weather = tool({
const tools = { get_weather, get_time, ... } const tools = { get_weather, get_time, ... }
const events = yield* LLM.stream( const events = yield* LLM.stream(
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }), LLM.updateRequest(request, { tools: Tool.toDefinitions(tools) }),
).pipe(Stream.runCollect) ).pipe(Stream.runCollect)
const call = Array.from(events).find(LLMEvent.is.toolCall) const call = Array.from(events).find(LLMEvent.is.toolCall)
+2 -3
View File
@@ -315,8 +315,7 @@ const longer = {
} }
``` ```
There is no `LLM.updateRequest(...)` helper. The current Schema-backed implementation There is no `LLM.updateRequest(...)` helper and no request Schema class.
uses `LLMRequest.update(...)` when canonical request data must be derived.
### Conversation history ### Conversation history
@@ -437,7 +436,7 @@ const call = Array.from(events).find(LLMEvent.is.toolCall)
if (call && !call.providerExecuted) { if (call && !call.providerExecuted) {
const dispatched = yield * ToolRuntime.dispatch(tools, call) const dispatched = yield * ToolRuntime.dispatch(tools, call)
const followUp = LLMRequest.update(request, { const followUp = LLM.updateRequest(request, {
messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })], messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })],
}) })
// Caller must invoke the provider again and repeat the loop. // Caller must invoke the provider again and repeat the loop.
+5 -7
View File
@@ -207,9 +207,7 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
### Auto placement ### Auto placement
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback. `"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless. The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
@@ -237,7 +235,7 @@ cache: {
### Manual hints ### Manual hints
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots. Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
```ts ```ts
LLM.request({ LLM.request({
@@ -253,8 +251,8 @@ LLM.request({
| Protocol | `cache: "auto"` | | Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) | | Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) | | Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | | OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | | Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
@@ -302,7 +300,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/google-vertex/responses` - `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages` - `@opencode-ai/ai/providers/google-vertex/messages`
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
+24 -24
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status # LLM Provider Parity Status
Last reviewed: 2026-07-24 Last reviewed: 2026-07-17
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -13,26 +13,26 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
## Current Implementation Snapshot ## Current Implementation Snapshot
| Native slice | Source | Current state | Main gaps | | Native slice | Source | Current state | Main gaps |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | | OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | | OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | | OpenAI 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 Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. | | OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. | | Anthropic-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. | | 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. | | Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. | | Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. | | Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
| Vertex Responses | `src/protocols/open-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through Open Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and an explicit `store: false` Vertex default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. | | Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. | | Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | | Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | | Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | | Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | | OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | | xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | | GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
## V2 Runner Status ## V2 Runner Status
@@ -65,7 +65,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
## Highest-Risk Gaps ## Highest-Risk Gaps
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. 1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
2. The Open Responses adapter is available through a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. 2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. 3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing. 4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
@@ -83,13 +83,13 @@ These are implementation/API slices, not separate npm packages.
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | | OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | | OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | | Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | | Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | | Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | | Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | | Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. | | Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | | Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | | Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | | Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
+4 -7
View File
@@ -1,5 +1,5 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai" import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route" import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers" import { OpenAI } from "@opencode-ai/ai/providers"
@@ -78,10 +78,7 @@ const streamText = LLM.stream(request).pipe(
Stream.tap((event) => Stream.tap((event) =>
Effect.sync(() => { Effect.sync(() => {
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
if (event.type === "finish") if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
process.stdout.write(
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
)
}), }),
), ),
Stream.runDrain, Stream.runDrain,
@@ -116,7 +113,7 @@ const streamWithTools = Effect.gen(function* () {
// A durable agent would persist these messages before starting another // A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead. // raw model turn. This tutorial keeps the boundary visible instead.
const followUp = LLMRequest.update(request, { const followUp = LLM.updateRequest(request, {
messages: [ messages: [
...request.messages, ...request.messages,
Message.assistant([event]), Message.assistant([event]),
@@ -197,7 +194,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
event: Schema.String, event: Schema.String,
initial: () => undefined, initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }], onHalt: () => [{ type: "finish", reason: "stop" }],
}, },
}) })
-1
View File
@@ -15,7 +15,6 @@
], ],
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./testing": "./src/testing.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"devDependencies": { "devDependencies": {
+25 -61
View File
@@ -2,31 +2,32 @@
// the policy designates. Runs once at compile time, before the per-protocol // the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest. // body builder, so the existing inline-hint lowering path handles the rest.
// //
// The default `"auto"` shape places breakpoints at the last tool definition, // The default `"auto"` shape places one breakpoint at the last tool definition,
// the first and last distinct system parts, and the conversation tail. This // one at the last system part, and one at the latest user message. This
// exposes reusable tool, base-agent, project, and session prefixes while // matches what production agent harnesses (LangChain's caching middleware,
// advancing the tail after each tool result keeps the previous cache entry // kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// within Anthropic's 20-block lookback during long agent turns. // latest user message stays put while a single turn explodes into many
// assistant/tool round-trips, so caching at that boundary lets every
// intra-turn API call hit the prefix.
// //
// Manual `cache: CacheHint` placements on individual parts are preserved and // Manual `cache: CacheHint` placements on individual parts are preserved
// count against the four-breakpoint budget; auto only fills remaining slots. // this function only fills gaps the caller left empty.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options" import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages" import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = { const AUTO: CachePolicyObject = {
tools: true, tools: true,
system: true, system: true,
messages: { tail: 1 }, messages: "latest-user-message",
} }
const NONE: CachePolicyObject = {} const NONE: CachePolicyObject = {}
const BREAKPOINT_CAP = 4
// Resolution rules: // Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it: // - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x, // Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins. // so a single reuse within 5 minutes already wins.
// - "auto" → tools + first/last system + final message boundary. // - "auto" → tools + system + latest user msg.
// - "none" → no auto placement; manual `CacheHint`s still flow. // - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for. // - object form → exactly what the caller asked for.
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
@@ -43,32 +44,18 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]
const makeHint = (ttlSeconds: number | undefined): CacheHint => const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
interface Budget { const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
remaining: number
}
const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools if (tools.length === 0) return tools
const last = tools.length - 1 const last = tools.length - 1
if (tools[last]!.cache || budget.remaining === 0) return tools if (tools[last]!.cache) return tools
budget.remaining -= 1
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool)) return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
} }
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => { const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
if (system.length === 0) return system if (system.length === 0) return system
let changed = false const last = system.length - 1
const next = system.map((part, index) => { if (system[last]!.cache) return system
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
budget.remaining -= 1
changed = true
return { ...part, cache: hint }
})
return changed ? next : system
} }
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number => const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
@@ -77,20 +64,14 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
// Mark the last text part of `messages[index]`. If no text part exists, mark // Mark the last text part of `messages[index]`. If no text part exists, mark
// the last content part regardless of type — that's the breakpoint position // the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too. // in tool-result-only messages too.
const markMessageAt = ( const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
messages: ReadonlyArray<Message>,
index: number,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages if (index < 0 || index >= messages.length) return messages
const target = messages[index]! const target = messages[index]!
if (target.content.length === 0) return messages if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]! const existing = target.content[markAt]!
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages if ("cache" in existing && existing.cache) return messages
budget.remaining -= 1
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent }) const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long // Single pass over `messages`, substituting the one updated entry. Long
@@ -105,42 +86,25 @@ const markMessages = (
messages: ReadonlyArray<Message>, messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>, strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint, hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => { ): ReadonlyArray<Message> => {
if (messages.length === 0) return messages if (messages.length === 0) return messages
if (strategy === "latest-user-message") if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget) if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
const start = Math.max(0, messages.length - strategy.tail) const start = Math.max(0, messages.length - strategy.tail)
let next = messages let next = messages
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget) for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
return next return next
} }
const countHints = (request: LLMRequest) =>
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
request.messages.reduce(
(count, message) =>
count +
message.content.reduce(
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
0,
),
0,
)
export const applyCachePolicy = (request: LLMRequest): LLMRequest => { export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
const policy = resolve(request.cache) const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds) const hint = makeHint(policy.ttlSeconds)
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) } const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools const system = policy.system ? markLastSystem(request.system, hint) : request.system
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages }) return LLMRequest.update(request, { tools, system, messages })
+21 -3
View File
@@ -9,13 +9,24 @@ import {
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Message, Message,
type ModelInput as SchemaModelInput,
SystemPart, SystemPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
type ContentPart, type ContentPart,
ToolResultPart,
} from "./schema" } from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool" import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
export type ModelInput = SchemaModelInput
export type MessageInput = Message.Input
export type ToolChoiceInput = ToolChoice.Input
export type ToolChoiceMode = ToolChoice.Mode
export type ToolResultInput = Parameters<typeof ToolResultPart.make>[0]
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ /** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput = Omit< export type RequestInput = Omit<
ConstructorParameters<typeof LLMRequest>[0], ConstructorParameters<typeof LLMRequest>[0],
@@ -23,9 +34,9 @@ export type RequestInput = Omit<
> & { > & {
readonly system?: string | SystemPart | ReadonlyArray<SystemPart> readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart> readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input> readonly messages?: ReadonlyArray<Message | MessageInput>
readonly tools?: ReadonlyArray<ToolDefinition.Input> readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input readonly toolChoice?: ToolChoiceInput
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"] readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -35,6 +46,10 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream export const stream = LLMClient.stream
export const requestInput = (input: LLMRequest): RequestInput => ({
...LLMRequest.input(input),
})
export const request = (input: RequestInput) => { export const request = (input: RequestInput) => {
const { const {
system: requestSystem, system: requestSystem,
@@ -59,11 +74,14 @@ export const request = (input: RequestInput) => {
}) })
} }
export const updateRequest = (input: LLMRequest, patch: Partial<RequestInput>) =>
request({ ...requestInput(input), ...patch })
const GENERATE_OBJECT_TOOL_NAME = "generate_object" const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice"> type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice" | "responseFormat">
export class GenerateObjectResponse<T> { export class GenerateObjectResponse<T> {
constructor( constructor(
+69 -206
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client" import { Route } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
@@ -8,18 +7,16 @@ import { Protocol } from "../route/protocol"
import { import {
LLMError, LLMError,
LLMEvent, LLMEvent,
mergeJsonRecords,
Usage, Usage,
type CacheHint, type CacheHint,
type FinishReasonDetails,
type FinishReason, type FinishReason,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ProviderOptions,
type ProviderMetadata, type ProviderMetadata,
type ToolCallPart, type ToolCallPart,
type ToolDefinition, type ToolDefinition,
type ToolContent,
type ToolResultPart, type ToolResultPart,
} from "../schema" } from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -34,29 +31,6 @@ const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderS
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages" export const PATH = "/messages"
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
}
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
}
export type ProviderOptionsInput = ProviderOptions & {
readonly anthropic?: OptionsInput
}
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
// ============================================================================= // =============================================================================
@@ -101,15 +75,6 @@ const AnthropicThinkingBlock = Schema.Struct({
cache_control: Schema.optional(AnthropicCacheControl), cache_control: Schema.optional(AnthropicCacheControl),
}) })
// Safety-filtered thinking arrives as an opaque encrypted `data` payload with
// no visible text. It must round-trip verbatim so multi-turn thinking + tool
// use conversations keep their reasoning continuity.
const AnthropicRedactedThinkingBlock = Schema.Struct({
type: Schema.tag("redacted_thinking"),
data: Schema.String,
cache_control: Schema.optional(AnthropicCacheControl),
})
const AnthropicToolUseBlock = Schema.Struct({ const AnthropicToolUseBlock = Schema.Struct({
type: Schema.tag("tool_use"), type: Schema.tag("tool_use"),
id: Schema.String, id: Schema.String,
@@ -171,7 +136,6 @@ type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicAssistantBlock = Schema.Union([ const AnthropicAssistantBlock = Schema.Union([
AnthropicTextBlock, AnthropicTextBlock,
AnthropicThinkingBlock, AnthropicThinkingBlock,
AnthropicRedactedThinkingBlock,
AnthropicToolUseBlock, AnthropicToolUseBlock,
AnthropicServerToolUseBlock, AnthropicServerToolUseBlock,
AnthropicServerToolResultBlock, AnthropicServerToolResultBlock,
@@ -195,7 +159,7 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool> type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([ const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }), Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
]) ])
@@ -235,27 +199,12 @@ const AnthropicBodyFields = {
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody> export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicUsage = Schema.StructWithRest( const AnthropicUsage = Schema.Struct({
Schema.Struct({ input_tokens: Schema.optional(Schema.Number),
input_tokens: Schema.optional(Schema.Number), output_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number), cache_creation_input_tokens: optionalNull(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number), cache_read_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number), })
server_tool_use: optionalNull(
Schema.StructWithRest(
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
output_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage> type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.Struct({ const AnthropicStreamBlock = Schema.Struct({
@@ -265,9 +214,6 @@ const AnthropicStreamBlock = Schema.Struct({
text: Schema.optional(Schema.String), text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String), thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String), signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown), input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming // *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating // delta) with the structured payload in `content` and the originating
@@ -305,12 +251,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState { interface ParserState {
readonly tools: ToolStream.State<number> readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage readonly usage?: Usage
readonly pendingFinish?: {
readonly reason: FinishReasonDetails
readonly providerMetadata?: ProviderMetadata
}
readonly lifecycle: Lifecycle.State readonly lifecycle: Lifecycle.State
} }
@@ -346,12 +287,6 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined return typeof anthropic.signature === "string" ? anthropic.signature : undefined
} }
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
@@ -362,7 +297,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) => const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({ type: "auto" as const }), auto: () => ({ type: "auto" as const }),
none: () => ({ type: "none" as const }), none: () => undefined,
required: () => ({ type: "any" as const }), required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }), tool: (name) => ({ type: "tool" as const, name }),
}) })
@@ -395,10 +330,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
const wireType = serverToolResultType(part.name) const wireType = serverToolResultType(part.name)
if (!wireType) if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
// Prefer the provider-owned replay payload; fall back to the result value for return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
}) })
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) { const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@@ -425,7 +357,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native // Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string. // content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: Tool.Content, item: ToolContent,
) { ) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }) return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
@@ -436,7 +368,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
// with existing cassettes and provider expectations. // with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part) if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package. // Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Tool.Content> = part.result.value const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem) return yield* Effect.forEach(content, lowerToolResultContentItem)
}) })
@@ -537,16 +469,11 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue continue
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible content.push({
// thinking; only signature-less parts carrying redactedData type: "thinking",
// round-trip as opaque redacted_thinking blocks. thinking: part.text,
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata) signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
const redactedData = redactedDataFromMetadata(part.providerMetadata) })
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
@@ -583,39 +510,39 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages return messages
}) })
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) { const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
const input = request.providerOptions?.anthropic
return {
thinking: yield* resolveThinking(input?.thinking),
effort: typeof input?.effort === "string" ? input.effort : undefined,
}
})
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) { const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
if (!ProviderShared.isRecord(input)) return undefined const thinking = anthropicOptions(request)?.thinking
if (input.type === "adaptive") { if (!ProviderShared.isRecord(thinking)) return undefined
if (thinking.type === "adaptive") {
const display = const display =
input.display === "summarized" thinking.display === "summarized"
? ("summarized" as const) ? ("summarized" as const)
: input.display === "omitted" : thinking.display === "omitted"
? ("omitted" as const) ? ("omitted" as const)
: undefined : undefined
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
} }
if (input.type === "disabled") return { type: "disabled" as const } if (thinking.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined if (thinking.type !== "enabled") return undefined
const budget = const budget =
typeof input.budgetTokens === "number" typeof thinking.budgetTokens === "number"
? input.budgetTokens ? thinking.budgetTokens
: typeof input.budget_tokens === "number" : typeof thinking.budget_tokens === "number"
? input.budget_tokens ? thinking.budget_tokens
: undefined : undefined
if (budget === undefined) if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget } return { type: "enabled" as const, budget_tokens: budget }
}) })
const outputConfig = (request: LLMRequest) => {
const effort = anthropicOptions(request)?.effort
return typeof effort === "string" ? { effort } : undefined
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
@@ -624,7 +551,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// over-mark we keep their tool hints and shed the message-tail ones first. // over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools = const tools =
request.tools.length === 0 request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined ? undefined
: request.tools.map((tool) => : request.tools.map((tool) =>
lowerTool( lowerTool(
@@ -633,8 +560,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
), ),
) )
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const system = const system =
request.system.length === 0 request.system.length === 0
? undefined ? undefined
@@ -649,7 +574,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
) )
} }
const options = yield* resolveOptions(request)
return { return {
model: request.model.id, model: request.model.id,
system, system,
@@ -662,8 +586,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP, top_p: generation?.topP,
top_k: generation?.topK, top_k: generation?.topK,
stop_sequences: generation?.stop, stop_sequences: generation?.stop,
thinking: options.thinking, thinking: yield* lowerThinking(request),
output_config: options.effort === undefined ? undefined : { effort: options.effort }, output_config: outputConfig(request),
} }
}) })
@@ -672,7 +596,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// ============================================================================= // =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => { const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length" if (reason === "max_tokens") return "length"
if (reason === "tool_use") return "tool-calls" if (reason === "tool_use") return "tool-calls"
if (reason === "refusal") return "content-filter" if (reason === "refusal") return "content-filter"
return "unknown" return "unknown"
@@ -682,8 +606,9 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// `input_tokens` is the *non-cached* count per the Messages API docs, with // `input_tokens` is the *non-cached* count per the Messages API docs, with
// cache reads and writes as separate fields. We sum them to derive the // cache reads and writes as separate fields. We sum them to derive the
// inclusive `inputTokens` the rest of the contract expects. Extended // inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are included in `output_tokens`; newer responses also // thinking tokens are *not* broken out by Anthropic — they're billed as
// expose that subset through `output_tokens_details.thinking_tokens`. // part of `output_tokens`, so `reasoningTokens` stays `undefined` and
// `outputTokens` carries the combined total.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
const nonCached = usage.input_tokens const nonCached = usage.input_tokens
@@ -696,7 +621,6 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
nonCachedInputTokens: nonCached, nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead, cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite, cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage }, providerMetadata: { anthropic: usage },
}) })
@@ -715,18 +639,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
const outputTokens = right.outputTokens ?? left.outputTokens const outputTokens = right.outputTokens ?? left.outputTokens
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
return new Usage({ return new Usage({
inputTokens, inputTokens,
outputTokens, outputTokens,
nonCachedInputTokens, nonCachedInputTokens,
cacheReadInputTokens, cacheReadInputTokens,
cacheWriteInputTokens, cacheWriteInputTokens,
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { providerMetadata: {
anthropic: anthropic: {
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {}, ...left.providerMetadata?.["anthropic"],
...right.providerMetadata?.["anthropic"],
},
}, },
}) })
} }
@@ -756,9 +680,7 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
name: SERVER_TOOL_RESULT_NAMES[block.type], name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true, providerExecuted: true,
// The complete payload is irreducible provider replay state: subsequent providerMetadata: anthropicMetadata({ blockType: block.type }),
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
}) })
} }
@@ -785,10 +707,6 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
tools: ToolStream.start(state.tools, event.index, { tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index), id: block.id ?? String(event.index),
name: block.name ?? "", name: block.name ?? "",
input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
? ProviderShared.encodeJson(block.input)
: undefined,
providerExecuted: block.type === "server_tool_use", providerExecuted: block.type === "server_tool_use",
}), }),
}, },
@@ -803,50 +721,20 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
] ]
} }
if (block.type === "text" && block.text !== undefined) { if (block.type === "text" && block.text) {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const id = `text-${event.index ?? 0}`
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
return [ return [
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle }, { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
events, events,
] ]
} }
if (block.type === "thinking" && block.thinking !== undefined) { if (block.type === "thinking" && block.thinking) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [
{
...state,
lifecycle: block.thinking
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
: lifecycle,
reasoningSignatures:
event.index === undefined || block.signature === undefined
? state.reasoningSignatures
: { ...state.reasoningSignatures, [event.index]: block.signature },
},
events,
]
}
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
// payload as `redactedData` metadata (same model as Vercel's
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
if (block.type === "redacted_thinking" && block.data !== undefined) {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningStart( lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ redactedData: block.data }),
),
}, },
events, events,
] ]
@@ -884,13 +772,18 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
} }
if (delta?.type === "signature_delta" && delta.signature) { if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0 const events: LLMEvent[] = []
return [ return [
{ {
...state, ...state,
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature }, lifecycle: Lifecycle.reasoningEnd(
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ signature: delta.signature }),
),
}, },
NO_EVENTS, events,
] satisfies StepResult ] satisfies StepResult
} }
@@ -921,53 +814,28 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const resultEvents = result.events ?? [] const resultEvents = result.events ?? []
const signature = state.reasoningSignatures[event.index]
const lifecycle = resultEvents.length const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events) ? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd( : Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`), Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events, events,
`reasoning-${event.index}`, `reasoning-${event.index}`,
signature === undefined ? undefined : anthropicMetadata({ signature }),
) )
events.push(...resultEvents) events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures } return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
}) })
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage)) const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
...state,
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
]
}
const onMessageStop = (state: ParserState): StepResult => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, { const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: state.pendingFinish?.reason ?? { reason: mapFinishReason(event.delta?.stop_reason),
normalized: "unknown", usage,
raw: undefined, providerMetadata: event.delta?.stop_sequence
}, ? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
usage: state.usage, : undefined,
providerMetadata: state.pendingFinish?.providerMetadata,
}) })
return [{ ...state, lifecycle }, events] return [{ ...state, lifecycle, usage }, events]
} }
// Prefix `error.type` so overloads, rate limits, and quota errors are visible // Prefix `error.type` so overloads, rate limits, and quota errors are visible
@@ -992,7 +860,6 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event) if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event) if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "error") return onError(event) if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS]) return Effect.succeed<StepResult>([state, NO_EVENTS])
} }
@@ -1013,11 +880,7 @@ export const protocol = Protocol.make({
}, },
stream: { stream: {
event: Protocol.jsonEvent(AnthropicEvent), event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({ initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
step, step,
}, },
}) })
+28 -90
View File
@@ -8,7 +8,6 @@ import {
Usage, Usage,
type CacheHint, type CacheHint,
type FinishReason, type FinishReason,
type FinishReasonDetails,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type ModelToolSchemaCompatibility, type ModelToolSchemaCompatibility,
@@ -66,15 +65,14 @@ const BedrockToolResultBlock = Schema.Struct({
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock> type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
const BedrockReasoningBlock = Schema.Struct({ const BedrockReasoningBlock = Schema.Struct({
reasoningContent: Schema.Union([ reasoningContent: Schema.Struct({
Schema.Struct({ reasoningText: Schema.optional(
reasoningText: Schema.Struct({ Schema.Struct({
text: Schema.String, text: Schema.String,
signature: Schema.optional(Schema.String), signature: Schema.optional(Schema.String),
}), }),
}), ),
Schema.Struct({ redactedContent: Schema.String }), }),
]),
}) })
const BedrockUserBlock = Schema.Union([ const BedrockUserBlock = Schema.Union([
@@ -155,12 +153,6 @@ const BedrockUsageSchema = Schema.Struct({
}) })
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema> type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
const BedrockStreamException = Schema.Struct({
message: Schema.optional(Schema.String),
originalMessage: Schema.optional(Schema.String),
originalStatusCode: Schema.optional(Schema.Number),
})
// Streaming event shape — the AWS event stream wraps each JSON payload by its // Streaming event shape — the AWS event stream wraps each JSON payload by its
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We // `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
// reconstruct that wrapping in `decodeFrames` below so the event schema can // reconstruct that wrapping in `decodeFrames` below so the event schema can
@@ -188,11 +180,6 @@ const BedrockEvent = Schema.Struct({
Schema.Struct({ Schema.Struct({
text: Schema.optional(Schema.String), text: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String), signature: Schema.optional(Schema.String),
// Blob fields in Bedrock's JSON event stream are base64 strings.
redactedContent: Schema.optional(Schema.String),
// Vercel's Bedrock provider exposes the same delta under
// Anthropic's shorter `data` spelling.
data: Schema.optional(Schema.String),
}), }),
), ),
}), }),
@@ -212,11 +199,11 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown), metrics: Schema.optional(Schema.Unknown),
}), }),
), ),
internalServerException: Schema.optional(BedrockStreamException), internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })),
modelStreamErrorException: Schema.optional(BedrockStreamException), modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })),
validationException: Schema.optional(BedrockStreamException), validationException: Schema.optional(Schema.Struct({ message: Schema.String })),
throttlingException: Schema.optional(BedrockStreamException), throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })),
serviceUnavailableException: Schema.optional(BedrockStreamException), serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })),
}) })
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent> type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -272,13 +259,6 @@ const reasoningSignature = (part: ReasoningPart) => {
) )
} }
const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string"
? bedrock.redactedData
: undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
toolUse: { toolUse: {
toolUseId: part.id, toolUseId: part.id,
@@ -368,13 +348,11 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue continue
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
const signature = reasoningSignature(part) content.push({
const redactedData = reasoningRedactedData(part) reasoningContent: {
if (signature === undefined && redactedData !== undefined) { reasoningText: { text: part.text, signature: reasoningSignature(part) },
content.push({ reasoningContent: { redactedContent: redactedData } }) },
continue })
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
@@ -414,13 +392,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// tools → system → messages order to favour the highest-impact prefixes. // tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints() const breakpoints = BedrockCache.breakpoints()
const toolConfig = const toolConfig =
request.tools.length > 0 request.tools.length > 0 && request.toolChoice?.type !== "none"
? { ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
: undefined : undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints) const messages = yield* lowerMessages(request, breakpoints)
@@ -457,10 +430,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// ============================================================================= // =============================================================================
const mapFinishReason = (reason: string): FinishReason => { const mapFinishReason = (reason: string): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence") return "stop" if (reason === "end_turn" || reason === "stop_sequence") return "stop"
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length" if (reason === "max_tokens") return "length"
if (reason === "tool_use") return "tool-calls" if (reason === "tool_use") return "tool-calls"
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter" if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
return "unknown" return "unknown"
} }
@@ -489,7 +461,7 @@ interface ParserState {
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and // Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt` // `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive. // can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
readonly hasToolCalls: boolean readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>> readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -540,26 +512,12 @@ const step = (state: ParserState, event: BedrockEvent) =>
const index = event.contentBlockDelta.contentBlockIndex const index = event.contentBlockDelta.contentBlockIndex
const reasoning = event.contentBlockDelta.delta.reasoningContent const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const redactedData = reasoning.redactedContent ?? reasoning.data
const providerMetadata = reasoning.signature
? bedrockMetadata({ signature: reasoning.signature })
: redactedData !== undefined
? bedrockMetadata({ redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || providerMetadata !== undefined
? Lifecycle.reasoningDelta(
state.lifecycle,
events,
`reasoning-${index}`,
reasoning.text ?? "",
providerMetadata,
)
: state.lifecycle
return [ return [
{ {
...state, ...state,
lifecycle, lifecycle: reasoning.text
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
: state.lifecycle,
reasoningSignatures: reasoning.signature reasoningSignatures: reasoning.signature
? { ...state.reasoningSignatures, [index]: reasoning.signature } ? { ...state.reasoningSignatures, [index]: reasoning.signature }
: state.reasoningSignatures, : state.reasoningSignatures,
@@ -620,30 +578,15 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [ return [
{ {
...state, ...state,
pendingFinish: { pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
},
}, },
[], [],
] as const ] as const
} }
if (event.metadata) { if (event.metadata) {
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage const usage = mapUsage(event.metadata.usage)
return [ return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
},
[],
] as const
} }
const exception = ( const exception = (
@@ -660,7 +603,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error", message: exception[1]?.message ?? "Bedrock Converse stream error",
code: exception[0], code: exception[0],
}), }),
}) })
@@ -676,13 +619,8 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
? (() => { ? (() => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, { Lifecycle.finish(state.lifecycle, events, {
reason: { reason:
...state.pendingFinish.reason, state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
normalized:
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
? "tool-calls"
: state.pendingFinish.reason.normalized,
},
usage: state.pendingFinish.usage, usage: state.pendingFinish.usage,
}) })
return events return events
@@ -53,22 +53,8 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
}) })
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength } cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
const messageType = decoded.headers[":message-type"]?.value if (decoded.headers[":message-type"]?.value !== "event") continue
if (messageType === "error") { const eventType = decoded.headers[":event-type"]?.value
const code = decoded.headers[":error-code"]?.value
const message = decoded.headers[":error-message"]?.value
return yield* ProviderShared.eventError(
route,
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
"Bedrock Converse event-stream error",
)
}
const eventType =
messageType === "event"
? decoded.headers[":event-type"]?.value
: messageType === "exception"
? decoded.headers[":exception-type"]?.value
: undefined
if (typeof eventType !== "string") continue if (typeof eventType !== "string") continue
const payload = utf8.decode(decoded.body) const payload = utf8.decode(decoded.body)
if (!payload) continue if (!payload) continue
+17 -48
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client" import { Route } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
@@ -12,11 +11,11 @@ import {
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ProviderOptions,
type ProviderMetadata, type ProviderMetadata,
type TextPart, type TextPart,
type ToolCallPart, type ToolCallPart,
type ToolDefinition, type ToolDefinition,
type ToolContent,
} from "../schema" } from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared" import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { GeminiToolSchema } from "./utils/gemini-tool-schema"
@@ -27,18 +26,6 @@ const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES) const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
export interface OptionsInput {
readonly [key: string]: unknown
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
}
}
export type ProviderOptionsInput = ProviderOptions & {
readonly gemini?: OptionsInput
}
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
// ============================================================================= // =============================================================================
@@ -216,9 +203,7 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => { const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
? google.functionCallId
: undefined
} }
const lowerToolCall = (part: ToolCallPart) => ({ const lowerToolCall = (part: ToolCallPart) => ({
@@ -289,7 +274,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}) })
continue continue
} }
const content: ReadonlyArray<Tool.Content> = part.result.value const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text) const text = content.filter((item) => item.type === "text").map((item) => item.text)
const media: GeminiInlineDataPart[] = [] const media: GeminiInlineDataPart[] = []
for (const item of content) { for (const item of content) {
@@ -315,22 +300,21 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents return contents
}) })
const resolveOptions = (request: LLMRequest) => { const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
const value = request.providerOptions?.gemini?.thinkingConfig
if (!ProviderShared.isRecord(value)) return {} const thinkingConfig = (request: LLMRequest) => {
const thinkingConfig = { const value = geminiOptions(request)?.thinkingConfig
if (!ProviderShared.isRecord(value)) return undefined
const result = {
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
} }
return { return Object.values(result).some((item) => item !== undefined) ? result : undefined
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
} }
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0 const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
const generation = request.generation const generation = request.generation
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = { const generationConfig = {
maxOutputTokens: generation?.maxTokens, maxOutputTokens: generation?.maxTokens,
@@ -338,14 +322,14 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
topP: generation?.topP, topP: generation?.topP,
topK: generation?.topK, topK: generation?.topK,
stopSequences: generation?.stop, stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig, thinkingConfig: thinkingConfig(request),
} }
return { return {
contents: yield* lowerMessages(request), contents: yield* lowerMessages(request),
systemInstruction: systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools tools: toolsEnabled
? [ ? [
{ {
functionDeclarations: request.tools.map((tool) => functionDeclarations: request.tools.map((tool) =>
@@ -354,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
}, },
] ]
: undefined, : undefined,
toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
generationConfig: Object.values(generationConfig).some((value) => value !== undefined) generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
? generationConfig ? generationConfig
: undefined, : undefined,
@@ -398,22 +382,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
finishReason === "SAFETY" || finishReason === "SAFETY" ||
finishReason === "BLOCKLIST" || finishReason === "BLOCKLIST" ||
finishReason === "PROHIBITED_CONTENT" || finishReason === "PROHIBITED_CONTENT" ||
finishReason === "SPII" || finishReason === "SPII"
finishReason === "MODEL_ARMOR" ||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
finishReason === "IMAGE_RECITATION" ||
finishReason === "LANGUAGE"
) )
return "content-filter" return "content-filter"
if ( if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
finishReason === "MALFORMED_FUNCTION_CALL" ||
finishReason === "UNEXPECTED_TOOL_CALL" ||
finishReason === "NO_IMAGE" ||
finishReason === "TOO_MANY_TOOL_CALLS" ||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
finishReason === "MALFORMED_RESPONSE"
)
return "error"
return "unknown" return "unknown"
} }
@@ -430,10 +402,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
) )
: state.lifecycle : state.lifecycle
Lifecycle.finish(lifecycle, events, { Lifecycle.finish(lifecycle, events, {
reason: { reason: mapFinishReason(state.finishReason, state.hasToolCalls),
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
raw: state.finishReason,
},
usage: state.usage, usage: state.usage,
}) })
return events return events
-1
View File
@@ -6,4 +6,3 @@ export * as OpenAIImages from "./openai-images"
export * as OpenAICompatibleChat from "./openai-compatible-chat" export * as OpenAICompatibleChat from "./openai-compatible-chat"
export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenAIResponses from "./openai-responses" export * as OpenAIResponses from "./openai-responses"
export * as OpenResponses from "./open-responses"
File diff suppressed because it is too large Load Diff
+18 -49
View File
@@ -1,16 +1,13 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client" import { Route } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError,
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type FinishReasonDetails,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
@@ -18,8 +15,8 @@ import {
type TextPart, type TextPart,
type ToolCallPart, type ToolCallPart,
type ToolDefinition, type ToolDefinition,
type ToolContent,
} from "../schema" } from "../schema"
import { classifyProviderFailure } from "../provider-error"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options" import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
@@ -131,7 +128,6 @@ const OpenAIChatUsage = Schema.Struct({
prompt_tokens_details: optionalNull( prompt_tokens_details: optionalNull(
Schema.Struct({ Schema.Struct({
cached_tokens: Schema.optional(Schema.Number), cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}), }),
), ),
completion_tokens_details: optionalNull( completion_tokens_details: optionalNull(
@@ -168,18 +164,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
const OpenAIChatChoice = Schema.Struct({ const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta), delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String), finish_reason: optionalNull(Schema.String),
native_finish_reason: optionalNull(Schema.String),
})
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
}) })
export const OpenAIChatEvent = Schema.Struct({ export const OpenAIChatEvent = Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)), choices: Schema.Array(OpenAIChatChoice),
usage: optionalNull(OpenAIChatUsage), usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
}) })
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent> export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number] type OpenAIChatRequestMessage = LLMRequest["messages"][number]
@@ -195,7 +184,7 @@ export interface ParserState {
readonly pendingTools: Partial<Record<number, PendingToolDelta>> readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent> readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage readonly usage?: Usage
readonly finishReason?: FinishReasonDetails readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State readonly lifecycle: Lifecycle.State
readonly reasoningField?: string readonly reasoningField?: string
readonly reasoningDetails: Array<unknown> readonly reasoningDetails: Array<unknown>
@@ -335,7 +324,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
continue continue
} }
const content: ReadonlyArray<Tool.Content> = part.result.value const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text) const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") }) messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const files = content.filter((item) => item.type === "file") const files = content.filter((item) => item.type === "file")
@@ -397,13 +386,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
return messages return messages
}) })
const lowerOptions = (request: LLMRequest) => { const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
const options = OpenAIOptions.resolve(request) const store = OpenAIOptions.store(request)
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
return { return {
...(options.store !== undefined ? { store: options.store } : {}), ...(store !== undefined ? { store } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}), ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
} }
} })
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing, // `fromRequest` returns the provider body only. Endpoint, auth, framing,
@@ -434,7 +424,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
presence_penalty: generation?.presencePenalty, presence_penalty: generation?.presencePenalty,
seed: generation?.seed, seed: generation?.seed,
stop: generation?.stop, stop: generation?.stop,
...lowerOptions(request), ...(yield* lowerOptions(request)),
} }
}) })
@@ -449,27 +439,24 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "length") return "length" if (reason === "length") return "length"
if (reason === "content_filter") return "content-filter" if (reason === "content_filter") return "content-filter"
if (reason === "function_call" || reason === "tool_calls") return "tool-calls" if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
if (reason === "error") return "error"
return "unknown" return "unknown"
} }
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a // OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive // `cached_tokens` subset, and `completion_tokens` (inclusive total) with
// total) with a `reasoning_tokens` subset. We pass the inclusive totals // a `reasoning_tokens` subset. We pass the inclusive totals through and
// through and derive the non-cached breakdown so the `LLM.Usage` contract is // derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides. // satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens const cached = usage.prompt_tokens_details?.cached_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite)) const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
return new Usage({ return new Usage({
inputTokens: usage.prompt_tokens, inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens, outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached, nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached, cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning, reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage }, providerMetadata: { openai: usage },
@@ -545,22 +532,10 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) => const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.error)
return yield* new LLMError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
}),
})
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0] const choice = event.choices[0]
const finishReason = choice?.finish_reason const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
: state.finishReason
const delta = choice?.delta const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? [] const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools let tools = state.tools
@@ -652,13 +627,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => { const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0 const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
? {
...state.finishReason,
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: undefined
const metadata = reasoningMetadata( const metadata = reasoningMetadata(
state.reasoningField, state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined, state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -1,22 +1,23 @@
import { Route, type RouteRoutedModelInput } from "../route/client" import { Route, type RouteRoutedModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { OpenResponses } from "./open-responses" import { OpenAIResponses } from "./openai-responses"
const ADAPTER = "openai-compatible-responses" const ADAPTER = "openai-compatible-responses"
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
/** /**
* Deployment adapter for providers that expose an Open Responses-compatible * Route for providers that expose an OpenAI Responses-compatible `/responses`
* `/responses` endpoint. Provider helpers configure identity, endpoint, and * endpoint. Provider helpers configure identity, endpoint, and auth before
* auth while the semantic protocol remains provider-neutral. * model selection while this route reuses the OpenAI Responses protocol.
*/ */
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
providerMetadataKey: "openresponses", providerMetadataKey: "openai",
protocol: OpenResponses.protocol, protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH), endpoint: Endpoint.path(OpenAIResponses.PATH),
transport: OpenResponses.httpTransport, transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
}) })
export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenAICompatibleResponses from "./openai-compatible-responses"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,4 @@
import { Buffer } from "node:buffer" import { Buffer } from "node:buffer"
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
@@ -10,6 +9,7 @@ import {
type ContentPart, type ContentPart,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ToolFileContent,
type TextPart, type TextPart,
type ToolResultPart, type ToolResultPart,
} from "../schema" } from "../schema"
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
}) })
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) => export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes) validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
+9 -12
View File
@@ -1,4 +1,4 @@
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema" import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
export interface State { export interface State {
readonly stepStarted: boolean readonly stepStarted: boolean
@@ -14,17 +14,14 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true } return { ...state, stepStarted: true }
} }
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id) const stepped = stepStart(state, events)
events.push(LLMEvent.textDelta({ id, text })) if (stepped.text.has(id)) {
return started events.push(LLMEvent.textDelta({ id, text }))
return stepped
}
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
return { ...stepped, text: new Set([...stepped.text, id]) }
} }
export const reasoningStart = ( export const reasoningStart = (
@@ -84,7 +81,7 @@ export const finish = (
state: State, state: State,
events: LLMEvent[], events: LLMEvent[],
input: { input: {
readonly reason: FinishReasonDetails readonly reason: FinishReason
readonly usage?: Usage readonly usage?: Usage
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
}, },
@@ -1,65 +0,0 @@
import { Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema"
export const ResponseIncludables = [
"file_search_call.results",
"web_search_call.results",
"web_search_call.action.sources",
"message.input_image.image_url",
"computer_call_output.output.image_url",
"code_interpreter_call.outputs",
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number]
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export interface Resolved {
readonly instructions?: string
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
readonly serviceTier?: ServiceTier
}
export const resolve = (request: LLMRequest): Resolved => {
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
const include = Array.isArray(input?.include)
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
: []
const reasoningSummary = input?.reasoningSummary
return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined,
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
? reasoningSummary
: undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
}
}
export * as OpenResponsesOptions from "./open-responses-options"
@@ -1,23 +1,85 @@
import { ReasoningEfforts } from "../../schema" import { Schema } from "effect"
import { OpenResponsesOptions } from "./open-responses-options" import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
import { ReasoningEfforts, TextVerbosity } from "../../schema"
export const OpenAIReasoningEfforts = ReasoningEfforts export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string export type OpenAIReasoningEffort = string
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this // Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`. // in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables export const OpenAIResponseIncludables = [
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable "file_search_call.results",
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers "web_search_call.results",
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier "web_search_call.action.sources",
"message.input_image.image_url",
"computer_call_output.output.image_url",
"code_interpreter_call.outputs",
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
export const OpenAIReasoningEffort = Schema.String
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string" export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
export const resolve = OpenResponsesOptions.resolve const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const options = (request: LLMRequest) => request.providerOptions?.openai
export const store = (request: LLMRequest): boolean | undefined => {
const value = options(request)?.store
return typeof value === "boolean" ? value : undefined
}
export const reasoningEffort = (request: LLMRequest): string | undefined => {
const value = options(request)?.reasoningEffort
return typeof value === "string" ? value : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
// Resolve the OpenAI Responses `include` field. Filters out unknown
// includable values defensively so a typo in upstream config drops the
// invalid entry instead of poisoning the wire body. An empty array (either
// passed directly or produced by filtering) is treated as "no include" and
// returns undefined so the request body omits the field entirely.
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
const value = options(request)?.include
if (!Array.isArray(value)) return undefined
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
return filtered.length > 0 ? filtered : undefined
}
export const promptCacheKey = (request: LLMRequest) => {
const value = options(request)?.promptCacheKey
return typeof value === "string" ? value : undefined
}
export const textVerbosity = (request: LLMRequest) => {
const value = options(request)?.textVerbosity
return isTextVerbosity(value) ? value : undefined
}
export const serviceTier = (request: LLMRequest) => {
const value = options(request)?.serviceTier
return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined
}
export const instructions = (request: LLMRequest) => {
const value = options(request)?.instructions
return typeof value === "string" ? value : undefined
}
export * as OpenAIOptions from "./openai-options" export * as OpenAIOptions from "./openai-options"
@@ -63,8 +63,6 @@ const openAI = (schema: JsonSchema): JsonSchema => {
return isRecord(normalized) ? normalized : { type: "object" } return isRecord(normalized) ? normalized : { type: "object" }
} }
const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
const modelCompatibility = ( const modelCompatibility = (
@@ -85,5 +83,4 @@ export const ToolSchemaProjection = {
modelCompatibility, modelCompatibility,
moonshot, moonshot,
openAI, openAI,
responses,
} as const } as const
+2 -1
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
} }
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500)) if (input.status !== undefined && input.status >= 500)
return new ProviderInternalReason({ return new ProviderInternalReason({
...common, ...common,
status: input.status, status: input.status,
@@ -145,6 +145,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if ( if (
input.status === 400 || input.status === 400 ||
input.status === 404 || input.status === 404 ||
input.status === 409 ||
input.status === 413 || input.status === 413 ||
input.status === 422 input.status === 422
) )
@@ -5,17 +5,12 @@ import type { ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
export const id = ProviderID.make("anthropic-compatible") export const id = ProviderID.make("anthropic-compatible")
export type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly provider?: string readonly provider?: string
readonly baseURL: string readonly baseURL: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
} }
export type Settings = ProviderPackage.Settings & export type Settings = ProviderPackage.Settings &
@@ -25,7 +20,6 @@ export type Settings = ProviderPackage.Settings &
) & { ) & {
readonly baseURL: string readonly baseURL: string
readonly provider?: string readonly provider?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
} }
export const routes = [AnthropicMessages.route] export const routes = [AnthropicMessages.route]
@@ -67,7 +61,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits, limits: settings.limits,
provider: settings.provider, provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
} }
+1 -11
View File
@@ -6,19 +6,11 @@ import { ProviderID, type ModelID } from "../schema"
import { AnthropicMessages } from "../protocols/anthropic-messages" import { AnthropicMessages } from "../protocols/anthropic-messages"
import { AnthropicCompatible } from "./anthropic-compatible" import { AnthropicCompatible } from "./anthropic-compatible"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
export const id = ProviderID.make("anthropic") export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route] export const routes = [AnthropicMessages.route]
export type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings & export type Settings = ProviderPackage.Settings &
( (
@@ -26,7 +18,6 @@ export type Settings = ProviderPackage.Settings &
| { readonly apiKey?: never; readonly authToken?: string } | { readonly apiKey?: never; readonly authToken?: string }
) & { ) & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
} }
const auth = (options: ProviderAuthOption<"optional">) => { const auth = (options: ProviderAuthOption<"optional">) => {
@@ -61,6 +52,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits, limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
} }
@@ -6,13 +6,9 @@ import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared" import { GoogleVertexShared } from "./google-vertex-shared"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const const VERSION = "vertex-2023-10-16" as const
// models.dev uses this provider id even though the API contract is Anthropic Messages. // models.dev uses this provider id even though the API contract is Anthropic Messages.
@@ -23,7 +19,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -32,7 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput readonly providerOptions?: ProviderOptions
} }
const route = Route.make({ const route = Route.make({
@@ -25,7 +25,6 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleResponses.route.with({ const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses", id: "google-vertex-responses",
provider: id, provider: id,
providerOptions: { openresponses: { store: false } },
}) })
export const routes = [route] export const routes = [route]
+2 -6
View File
@@ -4,12 +4,9 @@ import { Auth } from "../route/auth"
import { Route, type RouteDefaultsInput } from "../route/client" import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared" import { GoogleVertexShared } from "./google-vertex-shared"
export type GeminiOptionsInput = Gemini.OptionsInput
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
export type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput &
@@ -17,7 +14,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
} }
export type Settings = ProviderPackage.Settings & export type Settings = ProviderPackage.Settings &
@@ -28,7 +24,7 @@ export type Settings = ProviderPackage.Settings &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: Gemini.ProviderOptionsInput readonly providerOptions?: ProviderOptions
} }
const route = Route.make({ const route = Route.make({
+2 -5
View File
@@ -2,13 +2,11 @@ import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options" import type { ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema" import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
import { Gemini } from "../protocols/gemini" import { Gemini } from "../protocols/gemini"
import { GoogleImages } from "../protocols/google-images" import { GoogleImages } from "../protocols/google-images"
export type { GoogleImageOptions } from "../protocols/google-images" export type { GoogleImageOptions } from "../protocols/google-images"
export type GeminiOptionsInput = Gemini.OptionsInput
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
export const id = ProviderID.make("google") export const id = ProviderID.make("google")
@@ -17,13 +15,12 @@ export const routes = [Gemini.route]
export type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput readonly providerOptions?: ProviderOptions
} }
const auth = (options: ProviderAuthOption<"optional">) => { const auth = (options: ProviderAuthOption<"optional">) => {
@@ -1,20 +0,0 @@
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
}
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
}
export * as OpenResponsesProviderOptions from "./open-responses-options"
@@ -3,9 +3,7 @@ import { OpenAICompatibleResponses } from "../protocols/openai-compatible-respon
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options" import type { OpenAIProviderOptionsInput } from "./openai-options"
export type { OpenResponsesOptionsInput, OpenResponsesProviderOptionsInput } from "./open-responses-options"
export const id = ProviderID.make("openai-compatible") export const id = ProviderID.make("openai-compatible")
@@ -13,14 +11,13 @@ export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly provider?: string readonly provider?: string
readonly baseURL: string readonly baseURL: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL: string readonly baseURL: string
readonly provider?: string readonly provider?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export const routes = [OpenAICompatibleResponses.route] export const routes = [OpenAICompatibleResponses.route]
+15 -3
View File
@@ -1,10 +1,22 @@
import type { ProviderOptions } from "../schema" import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
import { mergeProviderOptions } from "../schema" import { mergeProviderOptions } from "../schema"
import type { OpenResponsesOptionsInput } from "./open-responses-options" import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
export type OpenAIOptionsInput = OpenResponsesOptionsInput export interface OpenAIOptionsInput {
readonly [key: string]: unknown
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto"
// OpenAI Responses `include` wire field. Mirrors the official SDK's
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
// native-SDK callers share one shape and no translation is required.
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: OpenAIServiceTier
}
export type OpenAIProviderOptionsInput = ProviderOptions & { export type OpenAIProviderOptionsInput = ProviderOptions & {
readonly openai?: OpenAIOptionsInput readonly openai?: OpenAIOptionsInput
+1 -2
View File
@@ -12,8 +12,7 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
* Examples: * Examples:
* *
* - `OpenAIChat.protocol` — chat completions style * - `OpenAIChat.protocol` — chat completions style
* - `OpenResponses.protocol` — provider-neutral Responses API baseline * - `OpenAIResponses.protocol` — responses API
* - `OpenAIResponses.protocol` — OpenAI extensions to that baseline
* - `AnthropicMessages.protocol` — messages API with content blocks * - `AnthropicMessages.protocol` — messages API with content blocks
* - `Gemini.protocol` — generateContent * - `Gemini.protocol` — generateContent
* - `BedrockConverse.protocol` — Converse with binary event-stream framing * - `BedrockConverse.protocol` — Converse with binary event-stream framing
+5 -2
View File
@@ -1,5 +1,4 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow") export const ProviderFailureClassification = Schema.Literal("context-overflow")
@@ -153,4 +152,8 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
* Anything thrown or yielded by a handler that is not a `ToolFailure` is * Anything thrown or yielded by a handler that is not a `ToolFailure` is
* treated as a defect and fails the stream. * treated as a defect and fails the stream.
*/ */
export class ToolFailure extends Tool.Error {} export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
+8 -14
View File
@@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's * - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their * `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`. * mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic's `outputTokens` includes extended thinking. Newer responses * Anthropic does *not* break extended-thinking out of `output_tokens`, so
* expose that subset as `output_tokens_details.thinking_tokens`, which maps * `reasoningTokens` is `undefined` and `outputTokens` carries the
* to `reasoningTokens`; older responses leave it undefined. * combined total — a documented limitation of the Anthropic API.
* *
* `providerMetadata` always carries the provider's raw usage payload — * `providerMetadata` always carries the provider's raw usage payload —
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.) * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
@@ -191,16 +191,10 @@ export const ToolError = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ToolError" }) }).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type<typeof ToolError> export type ToolError = Schema.Schema.Type<typeof ToolError>
export const FinishReasonDetails = Schema.Struct({
normalized: FinishReason,
raw: Schema.optional(Schema.String),
}).annotate({ identifier: "LLM.FinishReasonDetails" })
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
export const StepFinish = Schema.Struct({ export const StepFinish = Schema.Struct({
type: Schema.tag("step-finish"), type: Schema.tag("step-finish"),
index: Schema.Number, index: Schema.Number,
reason: FinishReasonDetails, reason: FinishReason,
usage: Schema.optional(Usage), usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.StepFinish" }) }).annotate({ identifier: "LLM.Event.StepFinish" })
@@ -208,7 +202,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const Finish = Schema.Struct({ export const Finish = Schema.Struct({
type: Schema.tag("finish"), type: Schema.tag("finish"),
reason: FinishReasonDetails, reason: FinishReason,
usage: Schema.optional(Usage), usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.Finish" }) }).annotate({ identifier: "LLM.Event.Finish" })
@@ -371,7 +365,7 @@ interface ResponseState {
readonly events: ReadonlyArray<LLMEvent> readonly events: ReadonlyArray<LLMEvent>
readonly message: Message readonly message: Message
readonly usage?: Usage readonly usage?: Usage
readonly finishReason?: FinishReasonDetails readonly finishReason?: FinishReason
readonly textParts: Readonly<Record<string, ContentAssembly>> readonly textParts: Readonly<Record<string, ContentAssembly>>
readonly reasoningParts: Readonly<Record<string, ContentAssembly>> readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>> readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
@@ -399,7 +393,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
return { return {
...state, ...state,
events, events,
finishReason: state.finishReason ?? { normalized: "error" }, finishReason: state.finishReason ?? "error",
} }
} }
return { return {
@@ -586,7 +580,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
message: Message, message: Message,
events: Schema.Array(LLMEvent), events: Schema.Array(LLMEvent),
usage: Schema.optional(Usage), usage: Schema.optional(Usage),
finishReason: FinishReasonDetails, finishReason: FinishReason,
}) { }) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */ /** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() { get text() {
+16 -5
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record" import { isRecord } from "../utils/record"
@@ -40,6 +40,8 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" }) }).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart> export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export { ToolContent, ToolFileContent, ToolTextContent }
const isToolResultValue = (value: unknown): value is ToolResultValue => const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) && isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
@@ -61,7 +63,7 @@ export const ToolResultValue = Object.assign(
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.Literal("content"), type: Schema.Literal("content"),
value: Schema.Array(Tool.Content), value: Schema.Array(ToolContent),
}), }),
]).annotate({ identifier: "LLM.ToolResult" }), ]).annotate({ identifier: "LLM.ToolResult" }),
{ {
@@ -77,16 +79,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput { export interface ToolOutput {
readonly structured: unknown readonly structured: unknown
readonly content: ReadonlyArray<Tool.Content> readonly content: ReadonlyArray<ToolContent>
} }
export const ToolOutput = Object.assign( export const ToolOutput = Object.assign(
Schema.Struct({ Schema.Struct({
structured: Schema.Unknown, structured: Schema.Unknown,
content: Schema.Array(Tool.Content), content: Schema.Array(ToolContent),
}).annotate({ identifier: "LLM.ToolOutput" }), }).annotate({ identifier: "LLM.ToolOutput" }),
{ {
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }), make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => { fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
switch (result.type) { switch (result.type) {
case "json": case "json":
@@ -259,6 +261,13 @@ export namespace ToolChoice {
} }
} }
export const ResponseFormat = Schema.Union([
Schema.Struct({ type: Schema.Literal("text") }),
Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
]).pipe(Schema.toTaggedUnion("type"))
export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String), id: Schema.optional(Schema.String),
model: ModelSchema, model: ModelSchema,
@@ -269,6 +278,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
generation: Schema.optional(GenerationOptions), generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
responseFormat: Schema.optional(ResponseFormat),
cache: Schema.optional(CachePolicy), cache: Schema.optional(CachePolicy),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {} }) {}
@@ -286,6 +296,7 @@ export namespace LLMRequest {
generation: request.generation, generation: request.generation,
providerOptions: request.providerOptions, providerOptions: request.providerOptions,
http: request.http, http: request.http,
responseFormat: request.responseFormat,
cache: request.cache, cache: request.cache,
metadata: request.metadata, metadata: request.metadata,
}) })
+5 -5
View File
@@ -251,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
// Auto-placement policy for prompt caching. The protocol-neutral lowering step // Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the // reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as // per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places // usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoints at the last tool definition, the first and last distinct system // breakpoint at the last tool definition, one at the last system part, and one
// parts, and the conversation tail. The rolling message breakpoint keeps a // at the latest user message. The combination of provider invalidation
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long // hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// tool loops. // lookback means three trailing breakpoints reliably cover the static prefix.
// //
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices. // object form to override individual choices.
-157
View File
@@ -1,157 +0,0 @@
export * as TestLLM from "./testing"
import { LLMClient, type Interface as LLMClientShape } from "./route/client"
import {
LLMEvent,
LLMResponse,
type FinishReasonDetails,
type LLMError,
type LLMRequest,
type UsageInput,
} from "./schema"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
export interface Interface {
readonly requests: LLMRequest[]
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
readonly always: (response: Response) => Effect.Effect<void>
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
readonly client: LLMClientShape
}
export interface LayerOptions {
readonly transformRequest?: (request: LLMRequest) => LLMRequest
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
readonly fallback?: Response
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = (
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
...events: readonly LLMEvent[]
) => [
LLMEvent.stepStart({ index: 0 }),
...events,
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
LLMEvent.finish({ reason: options.reason }),
]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
export const toolCalls = (...events: readonly LLMEvent[]) =>
complete({ reason: { normalized: "tool-calls" } }, ...events)
const textEvents = (value: string, id: string) => [
LLMEvent.textStart({ id }),
LLMEvent.textDelta({ id, text: value }),
LLMEvent.textEnd({ id }),
]
export const text = (value: string, id: string) => stop(...textEvents(value, id))
export const textWithUsage = (value: string, id: string, inputTokens: number) =>
complete(
{ reason: { normalized: "stop" }, usage: { inputTokens, nonCachedInputTokens: inputTokens } },
...textEvents(value, id),
)
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
export const layer = (options: LayerOptions = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const requests: LLMRequest[] = []
const responses: Response[] = []
let started = Deferred.makeUnsafe<void>()
let fallback = options.fallback
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const stream = ((request: LLMRequest) => {
requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
Deferred.doneUnsafe(waiting, Effect.void)
const response = responses.shift() ?? fallback
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
const streamed = toStream(response)
const gate = activeGate
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
}) as LLMClientShape["stream"]
const client = LLMClient.Service.of({
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
stream,
generate: (request) =>
stream(request).pipe(
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.flatMap((state) => {
const response = LLMResponse.complete(state)
if (response) return Effect.succeed(response)
return Effect.die("TestLLM response ended without a terminal finish event")
}),
),
})
return Service.of({
requests,
push: (...input) =>
Effect.sync(() => {
responses.push(...input)
}),
always: (response) =>
Effect.sync(() => {
fallback = response
}),
wait,
gate: Effect.gen(function* () {
const gate = {
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
release: yield* Latch.make(),
}
activeGate = gate
const release = Effect.sync(() => {
if (activeGate === gate) activeGate = undefined
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
yield* Effect.addFinalizer(() => release)
return {
started: Queue.take(gate.started),
release,
}
}),
client,
})
}),
)
export const clientLayer = Layer.effect(
LLMClient.Service,
Effect.map(Service, (service) => service.client),
)
export const push = (...responses: readonly Response[]) => Service.use((service) => service.push(...responses))
export const always = (response: Response) => Service.use((service) => service.always(response))
export const wait = (count: number) => Service.use((service) => service.wait(count))
export const gate = Service.use((service) => service.gate)
+1 -1
View File
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
return decodeAndExecute(tool, call).pipe( return decodeAndExecute(tool, call).pipe(
Effect.map((value) => result(call, value)), Effect.map((value) => result(call, value)),
Effect.catchTag("Tool.Error", (failure) => Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)), Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
), ),
) )
+6 -6
View File
@@ -1,7 +1,7 @@
import { Effect, JsonSchema, Schema } from "effect" import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import type { import type {
ToolCallPart, ToolCallPart,
ToolContent,
ToolDefinition as ToolDefinitionClass, ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType, ToolOutput as ToolOutputType,
} from "./schema" } from "./schema"
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = ( export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>, input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
) => ReadonlyArray<Tool.Content> ) => ReadonlyArray<ToolContent>
/** /**
* A type-safe LLM tool. Each tool bundles its own description, parameter * A type-safe LLM tool. Each tool bundles its own description, parameter
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure> readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content> readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown readonly toStructuredOutput?: (output: unknown) => unknown
} }
@@ -151,7 +151,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure> readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content> readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown readonly toStructuredOutput?: (output: unknown) => unknown
}): AnyExecutableTool }): AnyExecutableTool
export function make(config: { export function make(config: {
@@ -159,7 +159,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema
readonly execute?: undefined readonly execute?: undefined
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content> readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown readonly toStructuredOutput?: (output: unknown) => unknown
}): AnyTool }): AnyTool
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
} }
const project = ( const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined, toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined, toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown, parameters: unknown,
callID: ToolCallPart["id"], callID: ToolCallPart["id"],
+4 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src" import { LLM, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { Model } from "../src/schema" import { Model } from "../src/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish" event.type === "finish"
? { type: "finish", reason: { normalized: event.reason } } ? { type: "finish", reason: event.reason }
: { type: "text-delta", id: "text-0", text: event.text } : { type: "text-delta", id: "text-0", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({ const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
@@ -141,7 +141,7 @@ describe("llm route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const prepared = yield* llm.prepare( const prepared = yield* llm.prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }), LLM.updateRequest(request, { model: updateModel(request.model, { route: configuredGemini }) }),
) )
expect(prepared.route).toBe("gemini-fake") expect(prepared.route).toBe("gemini-fake")
@@ -174,7 +174,7 @@ describe("llm route", () => {
}) })
const prepared = yield* (yield* LLMClient.Service).prepare( const prepared = yield* (yield* LLMClient.Service).prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }), LLM.updateRequest(request, { model: updateModel(request.model, { route: duplicate }) }),
) )
expect(prepared.body).toEqual({ body: "late-default" }) expect(prepared.body).toEqual({ body: "late-default" })
+2 -26
View File
@@ -137,26 +137,15 @@ Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deploym
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") }) Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku") Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
Anthropic.configure({
apiKey: "anthropic-key",
providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 }, effort: "high" },
},
}).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids. // @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {}) Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source. // @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" }) Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled" } } } })
// @ts-expect-error Anthropic thinking budgets must be numbers.
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: "large" } } } })
AnthropicCompatible.configure({ AnthropicCompatible.configure({
apiKey: "messages-key", apiKey: "messages-key",
baseURL: "https://messages.example.com/v1", baseURL: "https://messages.example.com/v1",
provider: "example", provider: "example",
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}).model("compatible-model") }).model("compatible-model")
// @ts-expect-error Anthropic-compatible providers require a base URL. // @ts-expect-error Anthropic-compatible providers require a base URL.
AnthropicCompatible.configure({ apiKey: "messages-key" }) AnthropicCompatible.configure({ apiKey: "messages-key" })
@@ -170,19 +159,10 @@ AnthropicCompatible.model("compatible-model", {
}) })
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
Google.configure({
apiKey: "google-key",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}).model("gemini-2.5-flash")
// @ts-expect-error Google model selectors only accept model ids. // @ts-expect-error Google model selectors only accept model ids.
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {}) Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
// @ts-expect-error Gemini thinking budgets must be numbers.
Google.configure({ providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } } })
GoogleVertex.configure({ GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
apiKey: "vertex-key",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
}).model("gemini-3.5-flash")
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash") GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash") GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
// @ts-expect-error Vertex Gemini model selectors only accept model ids. // @ts-expect-error Vertex Gemini model selectors only accept model ids.
@@ -228,11 +208,7 @@ GoogleVertexResponses.configure({
project: "project", project: "project",
}) })
GoogleVertexMessages.configure({ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
accessToken: "vertex-token",
project: "project",
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" } },
}).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys. // @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6") GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
+8 -68
View File
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
}), }),
) )
// A single system block is both the first and last boundary, so the auto // No explicit cache field → auto policy fires → last system part + latest
// policy deduplicates it and still marks the conversation tail. // user message both get cache_control markers.
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }], messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
@@ -48,15 +48,12 @@ describe("applyCachePolicy", () => {
}), }),
) )
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () => it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: [ system: "Sys A",
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [ messages: [
Message.user("first user"), Message.user("first user"),
@@ -69,10 +66,7 @@ describe("applyCachePolicy", () => {
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [ system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
],
messages: [ messages: [
{ role: "user", content: [{ type: "text", text: "first user" }] }, { role: "user", content: [{ type: "text", text: "first user" }] },
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] }, { role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
@@ -126,10 +120,7 @@ describe("applyCachePolicy", () => {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: bedrockModel, model: bedrockModel,
system: [ system: "Sys",
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")], messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
cache: "auto", cache: "auto",
@@ -140,12 +131,7 @@ describe("applyCachePolicy", () => {
toolConfig: { toolConfig: {
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }], tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
}, },
system: [ system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
{ text: "Base agent" },
{ cachePoint: { type: "default" } },
{ text: "Project instructions" },
{ cachePoint: { type: "default" } },
],
messages: [ messages: [
{ role: "user", content: [{ text: "first user" }] }, { role: "user", content: [{ text: "first user" }] },
{ role: "assistant", content: [{ text: "reply" }] }, { role: "assistant", content: [{ text: "reply" }] },
@@ -207,55 +193,9 @@ describe("applyCachePolicy", () => {
}), }),
) )
const body = prepared.body as { const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
system: Array<{ text: string; cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" }) expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" }) expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
Effect.gen(function* () {
const request = LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "Base agent" },
{
type: "text",
text: "Manual context",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
},
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: "auto",
})
const applied = applyCachePolicy(request)
expect(applied.tools[0]?.cache).toBeDefined()
expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
const tail = applied.messages[0]!.content[0]!
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied)
const prepared = yield* LLMClient.prepare(request)
const body = prepared.body as {
tools: Array<{ cache_control?: unknown }>
system: Array<{ cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
const marked = [
...body.tools.map((tool) => tool.cache_control),
...body.system.map((part) => part.cache_control),
...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
].filter((cache) => cache !== undefined)
expect(marked).toHaveLength(4)
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
}), }),
) )
+1 -11
View File
@@ -11,15 +11,8 @@ import {
XAI, XAI,
} from "@opencode-ai/ai/providers" } from "@opencode-ai/ai/providers"
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot" import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
import { import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
OpenAIChat,
OpenAICompatibleChat,
OpenAICompatibleResponses,
OpenAIResponses,
OpenResponses,
} from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing"
describe("public exports", () => { describe("public exports", () => {
test("root exposes app-facing runtime APIs", () => { test("root exposes app-facing runtime APIs", () => {
@@ -29,7 +22,6 @@ describe("public exports", () => {
expect(ImageInput.bytes).toBeFunction() expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction() expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make) expect(ProviderSubpath.make).toBe(Provider.make)
expect(TestLLM.layer).toBeFunction()
}) })
test("route barrel exposes route-authoring APIs", () => { test("route barrel exposes route-authoring APIs", () => {
@@ -82,9 +74,7 @@ describe("public exports", () => {
test("protocol barrels expose supported low-level routes", () => { test("protocol barrels expose supported low-level routes", () => {
expect(OpenAIChat.route.id).toBe("openai-chat") expect(OpenAIChat.route.id).toBe("openai-chat")
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
expect(OpenResponses.protocol.id).toBe("open-responses")
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses") expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
expect(OpenAIResponses.route.id).toBe("openai-responses") expect(OpenAIResponses.route.id).toBe("openai-responses")
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
expect(AnthropicMessages.route.id).toBe("anthropic-messages") expect(AnthropicMessages.route.id).toBe("anthropic-messages")
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
const stepState = (events: ReadonlyArray<LLMEvent>) => { const stepState = (events: ReadonlyArray<LLMEvent>) => {
const assistantContent: ContentPart[] = [] const assistantContent: ContentPart[] = []
const toolCalls: ToolCallPart[] = [] const toolCalls: ToolCallPart[] = []
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" } let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
let usage: Usage | undefined let usage: Usage | undefined
let providerMetadata: ProviderMetadata | undefined let providerMetadata: ProviderMetadata | undefined
+4 -13
View File
@@ -2,16 +2,7 @@ import { describe, expect, test } from "bun:test"
import { CacheHint, LLM, LLMResponse } from "../src" import { CacheHint, LLM, LLMResponse } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses" import * as OpenAIResponses from "../src/protocols/openai-responses"
import { import { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
GenerationOptions,
LLMRequest,
Message,
Model,
ToolCallPart,
ToolChoice,
ToolDefinition,
ToolResultPart,
} from "../src/schema"
const chatRoute = OpenAIChat.route const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route const responsesRoute = OpenAIResponses.route
@@ -40,8 +31,8 @@ describe("llm constructors", () => {
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.", prompt: "Say hello.",
}) })
const updated = LLMRequest.update(base, { const updated = LLM.updateRequest(base, {
generation: GenerationOptions.make({ maxTokens: 20 }), generation: { maxTokens: 20 },
messages: [...base.messages, Message.assistant("Hi.")], messages: [...base.messages, Message.assistant("Hi.")],
}) })
@@ -200,7 +191,7 @@ describe("llm constructors", () => {
LLMResponse.text({ LLMResponse.text({
events: [ events: [
{ type: "text-delta", id: "text-0", text: "hi" }, { type: "text-delta", id: "text-0", text: "hi" },
{ type: "finish", reason: { normalized: "stop" } }, { type: "finish", reason: "stop" },
], ],
}), }),
).toBe("hi") ).toBe("hi")
-6
View File
@@ -58,12 +58,6 @@ describe("provider error classification", () => {
).toEqual(["ProviderInternal", "ProviderInternal"]) ).toEqual(["ProviderInternal", "ProviderInternal"])
}) })
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => { test("classifies nested provider codes when a top-level code is also present", () => {
expect( expect(
[ [
+4 -18
View File
@@ -59,7 +59,7 @@ describe("provider package entrypoints", () => {
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 }, limits: { context: 200_000, output: 64_000 },
providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, providerOptions: { openai: { reasoningEffort: "low", store: true } },
}) })
expect(String(selected.provider)).toBe("example") expect(String(selected.provider)).toBe("example")
@@ -72,7 +72,7 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ expect(selected.route.defaults.providerOptions).toEqual({
openresponses: { reasoningEffort: "low", store: true }, openai: { reasoningEffort: "low", store: true },
}) })
}) })
@@ -85,7 +85,6 @@ describe("provider package entrypoints", () => {
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } }, body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 }, limits: { context: 200_000, output: 64_000 },
providerOptions: { anthropic: { effort: "low" } },
}) })
expect(String(selected.provider)).toBe("example") expect(String(selected.provider)).toBe("example")
@@ -97,19 +96,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } }) expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ anthropic: { effort: "low" } })
})
test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { anthropic: { thinking: { type: "adaptive" } } },
})
expect(selected.route.defaults.providerOptions).toEqual({
anthropic: { thinking: { type: "adaptive" } },
})
}) })
test("requires an Anthropic-compatible base URL at runtime", async () => { test("requires an Anthropic-compatible base URL at runtime", async () => {
@@ -249,12 +235,12 @@ describe("provider package entrypoints", () => {
path: "/chat/completions", path: "/chat/completions",
}) })
expect(responses.route.id).toBe("google-vertex-responses") expect(responses.route.id).toBe("google-vertex-responses")
expect(responses.route.protocol).toBe("open-responses") expect(responses.route.protocol).toBe("openai-responses")
expect(responses.route.endpoint).toMatchObject({ expect(responses.route.endpoint).toMatchObject({
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi", baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses", path: "/responses",
}) })
expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } }) expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
}) })
test("rejects conflicting Vertex auth settings at runtime", async () => { test("rejects conflicting Vertex auth settings at runtime", async () => {
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src" import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic" import * as Anthropic from "../../src/providers/anthropic"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
@@ -24,39 +24,6 @@ const cacheRequest = LLM.request({
generation: { maxTokens: 16, temperature: 0 }, generation: { maxTokens: 16, temperature: 0 },
}) })
const lookup = ToolDefinition.make({
name: "lookup",
description: "Look up a fixture value.",
inputSchema: {
type: "object",
properties: { index: { type: "number" } },
required: ["index"],
additionalProperties: false,
},
})
const longToolTurn = [
Message.user("Run the fixture lookups."),
...Array.from({ length: 11 }, (_, index) => {
const id = `lookup_${index}`
return [
Message.assistant(ToolCallPart.make({ id, name: lookup.name, input: { index } })),
Message.tool({
id,
name: lookup.name,
result: `Fixture result ${index}. `.repeat(80),
}),
]
}).flat(),
]
const longToolTurnRequest = LLM.request({
id: "recorded_anthropic_cache_long_tool_turn",
model,
system: LARGE_CACHEABLE_SYSTEM,
messages: longToolTurn,
tools: [lookup],
generation: { maxTokens: 16, temperature: 0 },
})
const recorded = recordedTests({ const recorded = recordedTests({
prefix: "anthropic-messages-cache", prefix: "anthropic-messages-cache",
provider: "anthropic", provider: "anthropic",
@@ -83,28 +50,4 @@ describe("Anthropic Messages cache recorded", () => {
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
}), }),
) )
recorded.effect.with("keeps a long tool turn inside the cache lookback", { tags: ["cache", "tool"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(longToolTurnRequest)
const firstRead = first.usage?.cacheReadInputTokens ?? 0
const firstWrite = first.usage?.cacheWriteInputTokens ?? 0
const firstCached = firstRead + firstWrite
// The prefix may already be warm when recording, so either a read or a
// write establishes that Anthropic recognized the cache boundary.
expect(firstCached).toBeGreaterThan(0)
const second = yield* LLMClient.generate(
LLMRequest.update(longToolTurnRequest, {
messages: [
...longToolTurn,
Message.assistant("The fixture lookups are complete."),
Message.user("Reply exactly: OK"),
],
}),
)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(firstCached)
expect(second.usage?.cacheWriteInputTokens ?? 0).toBeLessThan(firstCached)
}),
)
}) })
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
@@ -60,7 +60,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers adaptive thinking settings with effort", () => it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, { LLM.updateRequest(request, {
providerOptions: { providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
}, },
@@ -74,42 +74,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("normalizes enabled and disabled thinking settings", () =>
Effect.gen(function* () {
const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
}),
)
const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
}),
)
const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}),
)
expect(enabled.body.thinking).toEqual({ type: "enabled", budget_tokens: 1_024 })
expect(legacy.body.thinking).toEqual({ type: "enabled", budget_tokens: 2_048 })
expect(disabled.body.thinking).toEqual({ type: "disabled" })
}),
)
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
}),
)
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
@@ -271,34 +235,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_choice_none",
model,
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }],
messages: [
Message.user("What is the weather?"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
],
toolChoice: "none",
cache: "none",
}),
)
expect(prepared.body.tools).toEqual([
{
name: "lookup",
description: "Look things up",
input_schema: { type: "object", properties: {} },
},
])
expect(prepared.body.tool_choice).toEqual({ type: "none" })
}),
)
// Regression: read tool results must stay structured so base64 media data is // Regression: read tool results must stay structured so base64 media data is
// not JSON-stringified into `tool_result.content`. // not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () => it.effect("lowers media tool-result content as structured blocks", () =>
@@ -445,34 +381,6 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
{ type: "reasoning", text: "visible", providerMetadata: { anthropic: { signature: "sig_1" } } },
]),
],
}),
)
expect(prepared.body).toMatchObject({
messages: [
{
role: "assistant",
content: [
{ type: "redacted_thinking", data: "opaque_1" },
{ type: "thinking", thinking: "visible", signature: "sig_1" },
],
},
],
})
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () => it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
@@ -506,351 +414,18 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } }, providerMetadata: { anthropic: { signature: "sig_1" } },
}) })
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
expect(response.message.content).toEqual([ expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" }, { type: "text", text: "Hello!" },
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
]) ])
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({
type: "finish", type: "finish",
reason: { normalized: "stop", raw: "end_turn" }, reason: "stop",
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
}) })
}), }),
) )
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event",
})
}),
)
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "message_start",
message: {
usage: {
input_tokens: 5,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
server_tool_use: { web_search_requests: 1, start_counter: 2 },
output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" },
},
},
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" },
future_terminal: { requests: 4 },
},
},
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: 7,
outputTokens: 8,
reasoningTokens: 3,
totalTokens: 15,
providerMetadata: {
anthropic: {
input_tokens: 5,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 },
output_tokens: 8,
output_tokens_details: {
thinking_tokens: 3,
start_detail: "preserved",
terminal_detail: "preserved",
},
future_terminal: { requests: 4 },
},
},
})
}),
)
it.effect("round-trips omitted thinking carried only by a signature delta", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "" },
},
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
])
}),
)
it.effect("retains a thinking signature supplied in content_block_start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "sig_1" },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
}),
)
it.effect("retains complete tool input from content_block_start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("retains empty text blocks", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "" }])
}),
)
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "redacted_thinking", data: "opaque_1" } },
{ type: "content_block_stop", index: 0 },
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find((event) => event.type === "reasoning-start")).toMatchObject({
providerMetadata: { anthropic: { redactedData: "opaque_1" } },
})
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { anthropic: { redactedData: "opaque_1" } } },
{ type: "text", text: "Hello" },
])
}),
)
it.effect("round-trips streamed redacted thinking with tool use into a continuation request", () =>
Effect.gen(function* () {
// Anthropic types `redacted_thinking.data` as an opaque string. Its
// contents are provider-owned and must be replayed without inspection.
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "redacted_thinking", data: redactedData },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [
Message.user("Say hello."),
response.message,
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
],
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Say hello." }] },
{
role: "assistant",
content: [
{ type: "redacted_thinking", data: redactedData },
{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: "sunny",
is_error: undefined,
cache_control: undefined,
},
],
},
])
}),
)
it.effect("maps context-window truncation to length", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "model_context_window_exceeded" },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
}),
)
it.effect("preserves pause_turn while normalizing it to stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
}),
)
it.effect("assembles streamed tool call input", () => it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
@@ -860,11 +435,10 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } }, { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
{ type: "content_block_stop", index: 0 }, { type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({ const usage = new Usage({
@@ -901,16 +475,10 @@ describe("Anthropic Messages route", () => {
providerExecuted: undefined, providerExecuted: undefined,
providerMetadata: undefined, providerMetadata: undefined,
}, },
{ { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "tool_use" },
usage,
providerMetadata: undefined,
},
{ {
type: "finish", type: "finish",
reason: { normalized: "tool-calls", raw: "tool_use" }, reason: "tool-calls",
providerMetadata: undefined, providerMetadata: undefined,
usage, usage,
}, },
@@ -1046,13 +614,10 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } }, { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
{ type: "content_block_stop", index: 2 }, { type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -1071,20 +636,10 @@ describe("Anthropic Messages route", () => {
name: "web_search", name: "web_search",
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] }, result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
providerExecuted: true, providerExecuted: true,
// The complete payload rides in provider metadata as irreducible replay providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
// state for later stateless requests.
providerMetadata: {
anthropic: {
blockType: "web_search_tool_result",
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
},
},
}) })
expect(response.text).toBe("Found it.") expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
})
}), }),
) )
@@ -1110,13 +665,10 @@ describe("Anthropic Messages route", () => {
}, },
{ type: "content_block_stop", index: 1 }, { type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
ToolDefinition.make({ name: "web_search", description: "Web search", inputSchema: { type: "object" } }),
],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -1232,10 +784,7 @@ describe("Anthropic Messages route", () => {
content: [ content: [
{ type: "text", text: "What is in this image?" }, { type: "text", text: "What is in this image?" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
type: "document",
source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" },
},
], ],
}, },
], ],
@@ -2,16 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
CacheHint,
GenerationOptions,
LLM,
LLMRequest,
Message,
ToolCallPart,
ToolChoice,
ToolDefinition,
} from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers" import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse" import * as BedrockConverse from "../../src/protocols/bedrock-converse"
@@ -43,26 +34,6 @@ const eventFrame = (type: string, payload: object) =>
body: utf8Encoder.encode(JSON.stringify(payload)), body: utf8Encoder.encode(JSON.stringify(payload)),
}) })
const exceptionFrame = (type: string, payload: object) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "exception" },
":exception-type": { type: "string", value: type },
":content-type": { type: "string", value: "application/json" },
},
body: utf8Encoder.encode(JSON.stringify(payload)),
})
const errorFrame = (code: string, message: string) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "error" },
":error-code": { type: "string", value: code },
":error-message": { type: "string", value: message },
},
body: new Uint8Array(),
})
const concat = (frames: ReadonlyArray<Uint8Array>) => { const concat = (frames: ReadonlyArray<Uint8Array>) => {
const total = frames.reduce((sum, frame) => sum + frame.length, 0) const total = frames.reduce((sum, frame) => sum + frame.length, 0)
const out = new Uint8Array(total) const out = new Uint8Array(total)
@@ -115,9 +86,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes topK through additionalModelRequestFields as top_k", () => it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLMRequest.update(baseRequest, { LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
}),
) )
// Converse's inferenceConfig has no topK; Anthropic/Nova read it from // Converse's inferenceConfig has no topK; Anthropic/Nova read it from
@@ -154,13 +123,13 @@ describe("Bedrock Converse route", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () => it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, { LLM.updateRequest(baseRequest, {
tools: [ tools: [
ToolDefinition.make({ {
name: "lookup", name: "lookup",
description: "Lookup data", description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
}), },
], ],
toolChoice: ToolChoice.make({ type: "required" }), toolChoice: ToolChoice.make({ type: "required" }),
}), }),
@@ -185,36 +154,6 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
}),
],
toolChoice: ToolChoice.make({ type: "none" }),
}),
)
expect(prepared.body.toolConfig).toMatchObject({
tools: [
{
toolSpec: {
name: "lookup",
description: "Lookup data",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
],
})
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
}),
)
it.effect("lowers assistant tool-call + tool-result message history", () => it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
@@ -321,10 +260,7 @@ describe("Bedrock Converse route", () => {
// `metadata` (carries usage). We consolidate them into a single // `metadata` (carries usage). We consolidate them into a single
// terminal `finish` event with both. // terminal `finish` event with both.
expect(finishes).toHaveLength(1) expect(finishes).toHaveLength(1)
expect(finishes[0]).toMatchObject({ expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
})
expect(response.usage).toMatchObject({ expect(response.usage).toMatchObject({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 2,
@@ -333,23 +269,6 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("maps truncation and malformed output stop reasons", () =>
Effect.gen(function* () {
const reasons = [
["model_context_window_exceeded", "length"],
["malformed_model_output", "error"],
["malformed_tool_use", "error"],
] as const
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () => it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = eventStreamBody( const body = eventStreamBody(
@@ -383,19 +302,6 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("preserves usage across later metadata events without usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStop", { stopReason: "end_turn" }],
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["metadata", { metrics: { latencyMs: 100 } }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("assembles streamed tool call input", () => it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = eventStreamBody( const body = eventStreamBody(
@@ -413,8 +319,8 @@ describe("Bedrock Converse route", () => {
["messageStop", { stopReason: "tool_use" }], ["messageStop", { stopReason: "tool_use" }],
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(baseRequest, { LLM.updateRequest(baseRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedBytes(body))) ).pipe(Effect.provide(fixedBytes(body)))
@@ -426,10 +332,7 @@ describe("Bedrock Converse route", () => {
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
]) ])
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
type: "finish",
reason: { normalized: "tool-calls", raw: "tool_use" },
})
}), }),
) )
@@ -455,7 +358,7 @@ describe("Bedrock Converse route", () => {
name: "lookup", name: "lookup",
raw: '{"query":"partial', raw: '{"query":"partial',
}) })
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" }) expect(response.finishReason).toBe("tool-calls")
}), }),
) )
@@ -511,170 +414,12 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }],
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { signature: "sig_1" } },
})
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Let me think.",
providerMetadata: { bedrock: { signature: "sig_1" } },
},
])
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
},
])
}),
)
it.effect("preserves signature-only reasoning blocks", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { signature: "sig_1" } } },
])
}),
)
it.effect("accepts Vercel-compatible redacted reasoning data deltas", () =>
Effect.gen(function* () {
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { data: redactedData } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
})
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData } } },
])
}),
)
it.effect("round-trips streamed redacted reasoning with tool use into a continuation request", () =>
Effect.gen(function* () {
// Bedrock represents redactedContent blobs as base64 strings on its JSON
// wire. The provider owns the payload and requires byte-exact replay.
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLMRequest.update(baseRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
[
"contentBlockStart",
{
contentBlockIndex: 1,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 1, delta: { toolUse: { input: '{"query":"weather"}' } } }],
["contentBlockStop", { contentBlockIndex: 1 }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
})
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
Message.user("Say hello."),
response.message,
Message.tool({ id: "tool_1", name: "lookup", result: "sunny", resultType: "text" }),
],
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ text: "Say hello." }] },
{
role: "assistant",
content: [
{ reasoningContent: { redactedContent: redactedData } },
{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } },
],
},
{
role: "user",
content: [{ toolResult: { toolUseId: "tool_1", content: [{ text: "sunny" }], status: "success" } }],
},
])
}),
)
it.effect("classifies throttlingException as a rate limit", () => it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = concat([ const body = eventStreamBody(
eventFrame("messageStart", { role: "assistant" }), ["messageStart", { role: "assistant" }],
exceptionFrame("throttlingException", { message: "Slow down" }), ["throttlingException", { message: "Slow down" }],
]) )
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip) const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" }) expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
@@ -685,7 +430,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe( const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide( Effect.provide(
fixedBytes(exceptionFrame("validationException", { message: "Input is too long for requested model" })), fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
), ),
Effect.flip, Effect.flip,
) )
@@ -698,44 +443,12 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("uses originalMessage from model stream exception frames", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
exceptionFrame("modelStreamErrorException", {
originalMessage: "Upstream model failed",
originalStatusCode: 500,
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
}),
)
it.effect("fails unmodeled AWS event-stream errors", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(errorFrame("BadStream", "Stream failed"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "BadStream: Stream failed",
})
}),
)
it.effect("rejects requests with no auth path", () => it.effect("rejects requests with no auth path", () =>
Effect.gen(function* () { Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({ const unsignedModel = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test", baseURL: "https://bedrock-runtime.test",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0") }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe( const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.flip, Effect.flip,
) )
@@ -754,7 +467,7 @@ describe("Bedrock Converse route", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}, },
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0") }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed })) const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse") expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed) expect(prepared.model).toBe(signed)
@@ -939,7 +652,6 @@ describe("Bedrock Converse route", () => {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ LLM.request({
model, model,
cache: "none",
messages: [ messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]), Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({ Message.tool({
+18 -88
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini" import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared" import { ProviderShared } from "../../src/protocols/shared"
@@ -36,27 +36,6 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("normalizes Gemini thinking options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0,
includeThoughts: false,
})
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () => it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>( const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
@@ -254,22 +233,20 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("keeps tools and sends function calling mode NONE", () => it.effect("omits tools when tool choice is none", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_choice_none", id: "req_no_tools",
model, model,
prompt: "Say hello.", prompt: "Say hello.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
toolChoice: { type: "none" }, toolChoice: { type: "none" },
}), }),
) )
expect(prepared.body).toMatchObject({ expect(prepared.body).toEqual({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }], contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
toolConfig: { functionCallingConfig: { mode: "NONE" } },
}) })
}), }),
) )
@@ -394,16 +371,10 @@ describe("Gemini route", () => {
{ type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" }, { type: "text-delta", id: "text-0", text: "!" },
{ type: "text-end", id: "text-0" }, { type: "text-end", id: "text-0" },
{ { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: "STOP" },
usage,
providerMetadata: undefined,
},
{ {
type: "finish", type: "finish",
reason: { normalized: "stop", raw: "STOP" }, reason: "stop",
usage, usage,
}, },
]) ])
@@ -431,8 +402,8 @@ describe("Gemini route", () => {
], ],
}) })
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
const reasoning = response.events.find((event) => event.type === "reasoning-start") const reasoning = response.events.find((event) => event.type === "reasoning-start")
@@ -522,8 +493,8 @@ describe("Gemini route", () => {
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 }, usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
}) })
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({ const usage = new Usage({
@@ -556,16 +527,10 @@ describe("Gemini route", () => {
providerExecuted: undefined, providerExecuted: undefined,
providerMetadata: undefined, providerMetadata: undefined,
}, },
{ { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: "STOP" },
usage,
providerMetadata: undefined,
},
{ {
type: "finish", type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" }, reason: "tool-calls",
usage, usage,
}, },
]) ])
@@ -589,8 +554,8 @@ describe("Gemini route", () => {
], ],
}) })
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -604,10 +569,7 @@ describe("Gemini route", () => {
}, },
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
]) ])
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
})
}), }),
) )
@@ -627,41 +589,9 @@ describe("Gemini route", () => {
) )
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(length.events.at(-1)).toMatchObject({ expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
type: "finish",
reason: { normalized: "length", raw: "MAX_TOKENS" },
})
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(filtered.events.at(-1)).toMatchObject({ expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
type: "finish",
reason: { normalized: "content-filter", raw: "SAFETY" },
})
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
["MODEL_ARMOR", "content-filter"],
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
["IMAGE_RECITATION", "content-filter"],
["LANGUAGE", "content-filter"],
["UNEXPECTED_TOOL_CALL", "error"],
["NO_IMAGE", "error"],
["IMAGE_OTHER", "unknown"],
["TOO_MANY_TOOL_CALLS", "error"],
["MISSING_THOUGHT_SIGNATURE", "error"],
["MALFORMED_RESPONSE", "error"],
] as const
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
}), }),
) )
+29 -51
View File
@@ -1,18 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
HttpOptions,
LLM,
LLMError,
LLMEvent,
LLMRequest,
Message,
Model,
ToolCallPart,
ToolDefinition,
Usage,
} from "../../src"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -173,7 +162,7 @@ describe("OpenAI Chat route", () => {
it.effect("adds native query params to the Chat Completions URL", () => it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }), model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}), }),
).pipe( ).pipe(
@@ -193,7 +182,7 @@ describe("OpenAI Chat route", () => {
it.effect("uses Azure api-key header for static OpenAI Chat keys", () => it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key", apiKey: "azure-key",
@@ -219,15 +208,15 @@ describe("OpenAI Chat route", () => {
it.effect("applies serializable HTTP overlays after payload lowering", () => it.effect("applies serializable HTTP overlays after payload lowering", () =>
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: model.route model: model.route
.with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } }) .with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
.model({ id: model.id }), .model({ id: model.id }),
http: HttpOptions.make({ http: {
body: { metadata: { source: "test" } }, body: { metadata: { source: "test" } },
headers: { authorization: "Bearer request", "x-custom": "yes" }, headers: { authorization: "Bearer request", "x-custom": "yes" },
query: { debug: "1" }, query: { debug: "1" },
}), },
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -550,7 +539,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5, prompt_tokens: 5,
completion_tokens: 2, completion_tokens: 2,
total_tokens: 7, total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 }, completion_tokens_details: { reasoning_tokens: 0 },
}), }),
) )
@@ -558,9 +547,8 @@ describe("OpenAI Chat route", () => {
const usage = new Usage({ const usage = new Usage({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 2,
nonCachedInputTokens: 2, nonCachedInputTokens: 4,
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0, reasoningTokens: 0,
totalTokens: 7, totalTokens: 7,
providerMetadata: { providerMetadata: {
@@ -568,7 +556,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5, prompt_tokens: 5,
completion_tokens: 2, completion_tokens: 2,
total_tokens: 7, total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 }, completion_tokens_details: { reasoning_tokens: 0 },
}, },
}, },
@@ -581,16 +569,10 @@ describe("OpenAI Chat route", () => {
{ type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" }, { type: "text-delta", id: "text-0", text: "!" },
{ type: "text-end", id: "text-0" }, { type: "text-end", id: "text-0" },
{ { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: "stop" },
usage,
providerMetadata: undefined,
},
{ {
type: "finish", type: "finish",
reason: { normalized: "stop", raw: "stop" }, reason: "stop",
usage, usage,
}, },
]) ])
@@ -630,7 +612,7 @@ describe("OpenAI Chat route", () => {
it.effect("parses and replays a configured custom reasoning field", () => it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () { Effect.gen(function* () {
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }) const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe( const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
Effect.provide( Effect.provide(
fixedResponse( fixedResponse(
sseEvents( sseEvents(
@@ -650,7 +632,9 @@ describe("OpenAI Chat route", () => {
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>( const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }), LLM.request({ model: custom, messages: [response.message] }),
) )
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }]) expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
])
}), }),
) )
@@ -661,8 +645,8 @@ describe("OpenAI Chat route", () => {
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 }, { type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
] ]
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -1034,8 +1018,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"), deltaChunk({}, "tool_calls"),
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -1053,14 +1037,8 @@ describe("OpenAI Chat route", () => {
providerExecuted: undefined, providerExecuted: undefined,
providerMetadata: undefined, providerMetadata: undefined,
}, },
{ { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
type: "step-finish", { type: "finish", reason: "tool-calls", usage: undefined },
index: 0,
reason: { normalized: "tool-calls", raw: "tool_calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
]) ])
}), }),
) )
@@ -1077,8 +1055,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"), deltaChunk({}, "tool_calls"),
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -1099,8 +1077,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"), deltaChunk({}, "tool_calls"),
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -1117,8 +1095,8 @@ describe("OpenAI Chat route", () => {
deltaChunk({}, "tool_calls"), deltaChunk({}, "tool_calls"),
) )
const error = yield* LLMClient.generate( const error = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body)), Effect.flip) ).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
@@ -1135,8 +1113,8 @@ describe("OpenAI Chat route", () => {
}), }),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
) )
const input = LLMRequest.update(request, { const input = LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}) })
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe( const streamError = yield* LLMClient.stream(input).pipe(
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src" import { LLM, Message, ToolCallPart } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
@@ -53,9 +53,9 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () => it.effect("prepares generic Chat target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
toolChoice: ToolChoice.make({ type: "required" }), toolChoice: { type: "required" },
}), }),
) )
@@ -232,10 +232,7 @@ describe("OpenAI-compatible Chat route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
type: "finish",
reason: { normalized: "stop", raw: "stop" },
})
}), }),
) )
}) })
@@ -1,22 +1,17 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src" import { LLM } from "../../src"
import { configure } from "../../src/providers/openai-compatible-responses" import { configure } from "../../src/providers/openai-compatible-responses"
import { OpenAI } from "../../src/providers"
import { OpenResponses } from "../../src/protocols/open-responses"
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
import { OpenAIResponses } from "../../src/protocols/openai-responses" import { OpenAIResponses } from "../../src/protocols/openai-responses"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
describe("Open Responses-compatible route", () => { describe("OpenAI-compatible Responses route", () => {
it.effect("uses the Open Responses baseline for a configured deployment", () => it.effect("reuses the OpenAI Responses protocol for a configured deployment", () =>
Effect.gen(function* () { Effect.gen(function* () {
expect(OpenAICompatibleResponses.route.body).toBe(OpenResponses.protocol.body) expect(OpenAICompatibleResponses.route.body).toBe(OpenAIResponses.protocol.body)
expect(OpenAICompatibleResponses.route.transport).toBe(OpenResponses.httpTransport) expect(OpenAICompatibleResponses.route.transport).toBe(OpenAIResponses.httpTransport)
expect(OpenAICompatibleResponses.route.body).not.toBe(OpenAIResponses.protocol.body)
const model = configure({ const model = configure({
apiKey: "test-key", apiKey: "test-key",
@@ -32,7 +27,7 @@ describe("Open Responses-compatible route", () => {
) )
expect(prepared.route).toBe("openai-compatible-responses") expect(prepared.route).toBe("openai-compatible-responses")
expect(prepared.protocol).toBe("open-responses") expect(prepared.protocol).toBe("openai-responses")
expect(prepared.model).toMatchObject({ expect(prepared.model).toMatchObject({
id: "example-model", id: "example-model",
provider: "example", provider: "example",
@@ -50,92 +45,9 @@ describe("Open Responses-compatible route", () => {
{ role: "system", content: "You are concise." }, { role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }, { role: "user", content: [{ type: "input_text", text: "Say hello." }] },
], ],
store: false,
stream: true, stream: true,
}) })
}), }),
) )
it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const error = yield* LLMClient.prepare(
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("Open Responses does not support provider-native tool image_generation")
}),
)
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
Message.assistant({
type: "text",
text: "Unclassified.",
providerMetadata: { openresponses: { phase: null } },
}),
],
}),
)
expect(prepared.body).toMatchObject({
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
it.effect("reads standard options from the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
})
}),
)
it.effect("does not interpret OpenAI hosted-tool items", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "web_search_call", id: "ws_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
providerMetadata: { openresponses: { responseId: "resp_1" } },
})
}),
)
}) })
@@ -1,18 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect" import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
LLM,
LLMError,
LLMEvent,
LLMRequest,
Message,
Model,
ToolCallPart,
ToolDefinition,
ToolResultPart,
Usage,
} from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
@@ -107,7 +96,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers semantic service tier options", () => it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } }) const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
const prepared = yield* LLMClient.prepare(input) const prepared = yield* LLMClient.prepare(input)
@@ -119,7 +108,7 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () => it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }), LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
) )
expect(prepared.body.reasoning).toEqual({ effort: "experimental" }) expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
@@ -129,7 +118,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits unsupported semantic service tiers", () => it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
) )
expect(prepared.body).not.toHaveProperty("service_tier") expect(prepared.body).not.toHaveProperty("service_tier")
@@ -139,9 +128,9 @@ describe("OpenAI Responses route", () => {
it.effect("flattens top-level object unions in function schemas", () => it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ tools: [
ToolDefinition.make({ {
name: "read", name: "read",
description: "Read a path or resource.", description: "Read a path or resource.",
inputSchema: { inputSchema: {
@@ -163,7 +152,7 @@ describe("OpenAI Responses route", () => {
}, },
], ],
}, },
}), },
], ],
}), }),
) )
@@ -218,7 +207,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses WebSocket target", () => it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: OpenAIResponses.webSocketRoute model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }), .model({ id: "gpt-4.1-mini" }),
@@ -302,7 +291,7 @@ describe("OpenAI Responses route", () => {
it.effect("adds native query params to the Responses URL", () => it.effect("adds native query params to the Responses URL", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }), model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}), }),
).pipe( ).pipe(
@@ -324,7 +313,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses Azure api-key header for static OpenAI Responses keys", () => it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key", apiKey: "azure-key",
@@ -351,7 +340,7 @@ describe("OpenAI Responses route", () => {
it.effect("loads OpenAI default auth from Effect Config", () => it.effect("loads OpenAI default auth from Effect Config", () =>
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
}), }),
).pipe( ).pipe(
@@ -372,7 +361,7 @@ describe("OpenAI Responses route", () => {
it.effect("lets explicit auth override OpenAI default API key auth", () => it.effect("lets explicit auth override OpenAI default API key auth", () =>
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
model: OpenAI.configure({ model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/", baseURL: "https://api.openai.test/v1/",
auth: Auth.bearer("oauth-token"), auth: Auth.bearer("oauth-token"),
@@ -832,7 +821,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5, input_tokens: 5,
output_tokens: 2, output_tokens: 2,
total_tokens: 7, total_tokens: 7,
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 },
}, },
}, },
@@ -842,9 +831,8 @@ describe("OpenAI Responses route", () => {
const usage = new Usage({ const usage = new Usage({
inputTokens: 5, inputTokens: 5,
outputTokens: 2, outputTokens: 2,
nonCachedInputTokens: 2, nonCachedInputTokens: 4,
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0, reasoningTokens: 0,
totalTokens: 7, totalTokens: 7,
providerMetadata: { providerMetadata: {
@@ -852,7 +840,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5, input_tokens: 5,
output_tokens: 2, output_tokens: 2,
total_tokens: 7, total_tokens: 7,
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 },
}, },
}, },
@@ -868,13 +856,13 @@ describe("OpenAI Responses route", () => {
{ {
type: "step-finish", type: "step-finish",
index: 0, index: 0,
reason: { normalized: "stop", raw: undefined }, reason: "stop",
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage, usage,
}, },
{ {
type: "finish", type: "finish",
reason: { normalized: "stop", raw: undefined }, reason: "stop",
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage, usage,
}, },
@@ -882,121 +870,6 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_commentary" },
},
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
{ type: "response.output_text.done", item_id: "msg_commentary" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_commentary", phase: "commentary" },
},
{
type: "response.output_item.added",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_final", phase: "final_answer" },
},
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { phase: null } },
},
])
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(prepared.body.input).toEqual([
{
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
])
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", delta: "orphaned" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("rejects reasoning events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
{ type: "response.reasoning_text.done" },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain(`${event.type} is missing item_id`)
}
}),
)
it.effect("maps incomplete response reasons", () => it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () { Effect.gen(function* () {
const generate = (incompleteDetails: object) => const generate = (incompleteDetails: object) =>
@@ -1014,13 +887,11 @@ describe("OpenAI Responses route", () => {
const length = yield* generate({ reason: "max_output_tokens" }) const length = yield* generate({ reason: "max_output_tokens" })
const contentFilter = yield* generate({ reason: "content_filter" }) const contentFilter = yield* generate({ reason: "content_filter" })
const unknown = yield* generate({}) const unknown = yield* generate({})
const custom = yield* generate({ reason: "provider_limit" })
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason, custom.finishReason]).toEqual([ expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
{ normalized: "length", raw: "max_output_tokens" }, "length",
{ normalized: "content-filter", raw: "content_filter" }, "content-filter",
{ normalized: "unknown", raw: undefined }, "unknown",
{ normalized: "unknown", raw: "provider_limit" },
]) ])
}), }),
) )
@@ -1075,8 +946,8 @@ describe("OpenAI Responses route", () => {
{ type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" }, { type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } }, { type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: { normalized: "stop", raw: undefined } }, { type: "finish", reason: "stop" },
]) ])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([ expect(response.message.content).toEqual([
@@ -1121,7 +992,7 @@ describe("OpenAI Responses route", () => {
it.effect("streams each reasoning summary part as a separate block", () => it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }), LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
fixedResponse( fixedResponse(
@@ -1167,8 +1038,8 @@ describe("OpenAI Responses route", () => {
id: "rs_1:1", id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}, },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } }, { type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: { normalized: "stop", raw: undefined } }, { type: "finish", reason: "stop" },
]) ])
}), }),
) )
@@ -1176,7 +1047,7 @@ describe("OpenAI Responses route", () => {
it.effect("closes reasoning summary parts when storage is not disabled", () => it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }), LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
fixedResponse( fixedResponse(
@@ -1503,8 +1374,8 @@ describe("OpenAI Responses route", () => {
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
const usage = new Usage({ const usage = new Usage({
@@ -1551,16 +1422,10 @@ describe("OpenAI Responses route", () => {
providerExecuted: undefined, providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } }, providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls", raw: undefined },
usage,
providerMetadata: undefined,
},
{ {
type: "finish", type: "finish",
reason: { normalized: "tool-calls", raw: undefined }, reason: "tool-calls",
providerMetadata: undefined, providerMetadata: undefined,
usage, usage,
}, },
@@ -1589,8 +1454,8 @@ describe("OpenAI Responses route", () => {
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
) )
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLM.updateRequest(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
@@ -1600,7 +1465,7 @@ describe("OpenAI Responses route", () => {
name: "lookup", name: "lookup",
raw: '{"query":"partial', raw: '{"query":"partial',
}) })
expect(response.finishReason.normalized).toBe("tool-calls") expect(response.finishReason).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse() expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}), }),
) )
@@ -1627,7 +1492,7 @@ describe("OpenAI Responses route", () => {
name: "lookup", name: "lookup",
raw: '{"query":"partial', raw: '{"query":"partial',
}) })
expect(response.finishReason.normalized).toBe("tool-calls") expect(response.finishReason).toBe("tool-calls")
}), }),
) )
@@ -4,8 +4,6 @@ import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter" import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
describe("OpenRouter", () => { describe("OpenRouter", () => {
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () => it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
@@ -56,42 +54,6 @@ describe("OpenRouter", () => {
}), }),
) )
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails on a mid-stream provider error", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
error: { code: 502, message: "Provider disconnected" },
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.message).toContain("Provider disconnected")
}),
)
it.effect("preserves manually supplied reasoning details", () => it.effect("preserves manually supplied reasoning details", () =>
Effect.gen(function* () { Effect.gen(function* () {
const details = [ const details = [
@@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({
}) })
const expectCode = (response: LLMResponse) => { const expectCode = (response: LLMResponse) => {
expect(response.finishReason.normalized).toBe("stop") expect(response.finishReason).toBe("stop")
expect(response.text.toUpperCase()).toContain(CODE) expect(response.text.toUpperCase()).toContain(CODE)
} }
@@ -166,7 +166,7 @@ describe("PDF recorded", () => {
tools: { read_pdf: readPdfRuntime }, tools: { read_pdf: readPdfRuntime },
}).pipe(Stream.runCollect), }).pipe(Stream.runCollect),
) )
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } }) expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE) expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
return return
} }
+10 -8
View File
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { import {
LLM, LLM,
LLMEvent, LLMEvent,
LLMRequest,
LLMResponse, LLMResponse,
Message, Message,
ToolRuntime, ToolRuntime,
@@ -12,6 +11,7 @@ import {
toDefinitions, toDefinitions,
type ContentPart, type ContentPart,
type FinishReason, type FinishReason,
type LLMRequest,
type Model, type Model,
} from "../src" } from "../src"
import { LLMClient } from "../src/route" import { LLMClient } from "../src/route"
@@ -91,7 +91,7 @@ const restroomImage = () =>
export const runWeatherToolLoop = (request: LLMRequest) => export const runWeatherToolLoop = (request: LLMRequest) =>
Effect.gen(function* () { Effect.gen(function* () {
const tools = { [weatherToolName]: weatherRuntimeTool } const tools = { [weatherToolName]: weatherRuntimeTool }
let next = LLMRequest.update(request, { tools: toDefinitions(tools) }) let next = LLM.updateRequest(request, { tools: toDefinitions(tools) })
const events: LLMEvent[] = [] const events: LLMEvent[] = []
for (let step = 0; step < 10; step++) { for (let step = 0; step < 10; step++) {
@@ -108,7 +108,7 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
ToolRuntime.dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)), ToolRuntime.dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
) )
events.push(...dispatched.flatMap(([, result]) => result.events)) events.push(...dispatched.flatMap(([, result]) => result.events))
next = LLMRequest.update(next, { next = LLM.updateRequest(next, {
messages: [ messages: [
...next.messages, ...next.messages,
Message.assistant(assistantContent(response.events)), Message.assistant(assistantContent(response.events)),
@@ -123,8 +123,10 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
const assistantContent = (events: ReadonlyArray<LLMEvent>) => const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
export const expectFinish = (events: ReadonlyArray<LLMEvent>, reason: FinishReason) => export const expectFinish = (
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } }) events: ReadonlyArray<LLMEvent>,
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
export const expectWeatherToolCall = (response: LLMResponse) => export const expectWeatherToolCall = (response: LLMResponse) =>
expect(response.toolCalls).toMatchObject([ expect(response.toolCalls).toMatchObject([
@@ -134,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => { export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
const finishes = events.filter(LLMEvent.is.finish) const finishes = events.filter(LLMEvent.is.finish)
expect(finishes).toHaveLength(1) expect(finishes).toHaveLength(1)
expect(finishes[0]?.reason.normalized).toBe("stop") expect(finishes[0]?.reason).toBe("stop")
const stepFinishes = events.filter(LLMEvent.is.stepFinish) const stepFinishes = events.filter(LLMEvent.is.stepFinish)
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"]) expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
const toolCalls = events.filter(LLMEvent.is.toolCall) const toolCalls = events.filter(LLMEvent.is.toolCall)
expect(toolCalls).toHaveLength(1) expect(toolCalls).toHaveLength(1)
@@ -501,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
continue continue
} }
if (event.type === "finish") { if (event.type === "finish") {
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) }) summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
} }
} }
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined))) return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
+7 -15
View File
@@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
LLMEvent.textDelta({ id: "t1", text: "Answer" }), LLMEvent.textDelta({ id: "t1", text: "Answer" }),
LLMEvent.textEnd({ id: "t1" }), LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }), LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
] ]
const response = LLMResponse.fromEvents(events) const response = LLMResponse.fromEvents(events)
expect(response?.finishReason).toEqual({ normalized: "stop" }) expect(response?.finishReason).toBe("stop")
expect(response?.usage).toMatchObject({ outputTokens: 5 }) expect(response?.usage).toMatchObject({ outputTokens: 5 })
expect(response?.events).toEqual(events) expect(response?.events).toEqual(events)
expect(response?.events.map((event) => event.type)).toEqual([ expect(response?.events.map((event) => event.type)).toEqual([
@@ -62,26 +62,18 @@ describe("LLMResponse reducer", () => {
test("uses terminal usage when present and keeps prior usage when finish omits it", () => { test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
const withFinishUsage = LLMResponse.fromEvents([ const withFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }), LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }), LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
]) ])
const withoutFinishUsage = LLMResponse.fromEvents([ const withoutFinishUsage = LLMResponse.fromEvents([
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }), LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
LLMEvent.finish({ reason: { normalized: "stop" } }), LLMEvent.finish({ reason: "stop" }),
]) ])
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 }) expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 }) expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
}) })
test("preserves the raw finish reason", () => {
const response = LLMResponse.fromEvents([
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
])
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
})
test("assembles tool-call content only after the completed tool call event", () => { test("assembles tool-call content only after the completed tool call event", () => {
const pending = reduce([ const pending = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
@@ -96,7 +88,7 @@ describe("LLMResponse reducer", () => {
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }), LLMEvent.finish({ reason: "tool-calls" }),
]) ])
expect(response?.message.content).toEqual([ expect(response?.message.content).toEqual([
+2 -6
View File
@@ -48,12 +48,8 @@ describe("llm schema", () => {
}) })
test("finish constructors accept usage input", () => { test("finish constructors accept usage input", () => {
expect( expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage, expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
Usage,
)
}) })
test("content part tagged union exposes guards", () => { test("content part tagged union exposes guards", () => {
+4 -7
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { import {
GenerationOptions, GenerationOptions,
@@ -8,6 +7,7 @@ import {
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
ToolChoice, ToolChoice,
ToolContent,
ToolOutput, ToolOutput,
toDefinitions, toDefinitions,
} from "../src" } from "../src"
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
it.effect("models canonical tool files with URIs", () => it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => { Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content) const decode = Schema.decodeUnknownSync(ToolContent)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({ expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file", type: "file",
@@ -539,7 +539,6 @@ describe("LLMClient tools", () => {
}, },
{ type: "content_block_stop", index: 1 }, { type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } }, { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
{ type: "message_stop" },
) )
: sseEvents( : sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } }, { type: "message_start", message: { usage: { input_tokens: 5 } } },
@@ -547,7 +546,6 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } }, { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 0 }, { type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
), ),
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
) )
@@ -555,7 +553,7 @@ describe("LLMClient tools", () => {
) )
yield* TestToolRuntime.runTools({ yield* TestToolRuntime.runTools({
request: LLMRequest.update(baseRequest, { request: LLM.updateRequest(baseRequest, {
model: AnthropicMessages.route model: AnthropicMessages.route
.with({ auth: Auth.header("x-api-key", "test") }) .with({ auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" }), .model({ id: "claude-sonnet-4-5" }),
@@ -803,7 +801,6 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } }, { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 2 }, { type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
), ),
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
) )
@@ -811,7 +808,7 @@ describe("LLMClient tools", () => {
) )
const events = Array.from( const events = Array.from(
yield* TestToolRuntime.runTools({ yield* TestToolRuntime.runTools({
request: LLMRequest.update(baseRequest, { request: LLM.updateRequest(baseRequest, {
model: AnthropicMessages.route model: AnthropicMessages.route
.with({ auth: Auth.header("x-api-key", "test") }) .with({ auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" }), .model({ id: "claude-sonnet-4-5" }),
-220
View File
@@ -1,220 +0,0 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
@@ -20,7 +20,7 @@ const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{ {
name: "multi patch", name: "multi patch",
tool: "apply_patch", tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
}, },
] as const ] as const
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"), textPart(followingID, "Following incremental patch"),
], ],
{ completed: false }, { completed: false },
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"running", "running",
{ files: [first.filePath, second.filePath] }, { files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } }, { metadata: { files: [first, second] } },
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: [first.filePath, second.filePath, third.filePath] }, { files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } }, { metadata: { files: [first, second, third] } },
@@ -97,7 +97,6 @@ export async function setupTimeline(
locale?: string locale?: string
deviceScaleFactor?: number deviceScaleFactor?: number
seedHistory?: boolean seedHistory?: boolean
protocol?: "v1" | "v2"
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
@@ -115,7 +114,6 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: input.protocol,
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
@@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
} }
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" } const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question" const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [ const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])), ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()), toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"), textPart("prt_state_following", "Following lightweight tools"),
] ]
const childID = "ses_timeline_child" const childID = "ses_timeline_child"
@@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
await timeline.send(status("busy"), 120) await timeline.send(status("busy"), 120)
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`) for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
const regionIDs = [ const regionIDs = [
"prt_state_webfetch", "prt_state_webfetch",
@@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
]), ]),
) )
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable") await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
await expect( await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible() ).toBeVisible()
@@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants] const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID const lastID = userID
const lastAssistant = assistants.at(-1)! const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000) benchmark.setTimeout(180_000)
@@ -112,25 +107,9 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
}, },
}) })
await page.route(`**/session/${fixture.targetID}`, (route) => { await page.route(`**/session/${fixture.targetID}`, (route) =>
const current = new URL(route.request().url()).pathname.startsWith("/api/") route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
return route.fulfill({ )
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID)) await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle) await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -165,8 +144,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length, parent: requests.filter((request) => request.type === "parent").length,
} }
if (mode === "candidate") { if (mode === "candidate") {
expect(requestCounts.parent).toBe(0) expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(0) expect(historyGates).toBe(1)
} }
return { metrics, requestCounts, historyGateCount: historyGates } return { metrics, requestCounts, historyGateCount: historyGates }
} }
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
messageID: assistantID, messageID: assistantID,
type: "tool", type: "tool",
callID: `call_0000_${suffix}_patch`, callID: `call_0000_${suffix}_patch`,
tool: "apply_patch", tool: "patch",
state: { state: {
status: "completed", status: "completed",
input: { patchText: realisticPatch(index) }, input: { patchText: realisticPatch(index) },
@@ -131,7 +131,7 @@ function toolPart(
): MessagePart { ): MessagePart {
const metadata = const metadata =
metadataOverride ?? metadataOverride ??
(tool === "apply_patch" (tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []), : []),
...(index % 8 === 0 ...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []), : []),
...(index % 7 === 0 ...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
@@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
]).flat() ]).flat()
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim() if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim() if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -34,7 +33,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await tabA.locator('[data-slot="tab-close"] button').click() await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@@ -85,21 +84,17 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/session") return json(route, [current])
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current) if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -121,20 +116,7 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory, directory: current.directory,
home: current.directory, home: current.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -1,132 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-expand",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
{
name: "app.ts",
path: "frontend\\app.ts",
absolute: `${directory}/frontend/app.ts`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
return [
{
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
ignored: false,
},
{
name: "README.md",
path: "README.md",
absolute: `${directory}/README.md`,
type: "file" as const,
ignored: false,
},
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
})
@@ -1,7 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -18,7 +17,7 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,") await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const dialog = page.locator(".settings-v2-dialog") const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -59,7 +58,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible() await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press("Control+,") await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click() await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked() await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -181,36 +180,10 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") if (url.pathname === "/session/status") return json(route, {})
return json(route, { data: [] }) if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project") {
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`) const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current) if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -243,12 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory, directory,
home: directory, home: directory,
}) })
if (url.pathname === "/api/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {}) return json(route, {})
}) })
} }
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -58,19 +57,15 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return sse(route, url.pathname === "/api/event") if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/session/status")
if (url.pathname === "/api/health") return json(route, { pid: 1 }) return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
if (url.pathname === "/api/session/active") if (url.pathname === "/session") return json(route, [current])
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current) if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
@@ -95,20 +90,7 @@ async function mockServers(page: Page) {
directory: current.directory, directory: current.directory,
home: current.directory, home: current.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -122,10 +104,6 @@ function json(route: Route, body: unknown, status = 200) {
}) })
} }
function sse(route: Route, current: boolean) { function sse(route: Route) {
return route.fulfill({ return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
status: 200,
contentType: "text/event-stream",
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
})
} }
@@ -84,7 +84,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
async function openReview(page: Page) { async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 }) await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: "proj_review_line_comment_regression", id: "proj_review_line_comment_regression",
@@ -144,9 +143,9 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click() await page.getByRole("tab", { name: "Changes" }).click()
expect((await (await diffResponse).json()).data).toHaveLength(1) expect(await (await diffResponse).json()).toHaveLength(1)
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click() await panel.getByRole("tab", { name: /Review/ }).click()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click() await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w") await page.keyboard.press("Control+w")
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
async function selectMode(page: Page, current: string, next: string) { async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click() await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click") await page.getByRole("option", { name: next }).click()
} }
async function selectFile(page: Page, file: string) { async function selectFile(page: Page, file: string) {
@@ -65,7 +65,6 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -20,12 +20,10 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = [] const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1 let detailVersion = 1
let detailFailures = 1 let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -57,7 +55,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
}, },
], ],
sessionStatus: () => sessionStatus, sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
@@ -66,10 +64,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
branch: "review-pane-performance",
default_branch: "dev",
}),
}), }),
) )
await page.route("**/vcs/diff**", (route) => { await page.route("**/vcs/diff**", (route) => {
@@ -91,51 +86,15 @@ test("keeps the review tree and terminal sized when both panels are open", async
), ),
}) })
}) })
await page.route("**/pty*", (route) => await page.route("**/pty", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}), }),
) )
await page.route("**/pty/pty_review_terminal*", (route) => await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
) )
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -184,7 +143,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1") await expect(preview).toContainText("after-1")
detailVersion = 2 detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy")) events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => { const refreshedDiff = page.waitForRequest((request) => {
@@ -194,7 +152,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle")) events.push(statusEvent("idle"))
await refreshedDiff await refreshedDiff
await expect(preview).toContainText("after-2") await expect(preview).toContainText("after-2")
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => { const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve releasePath = resolve
}) })
await page.route("**/api/path?*", async (route) => { await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await pathBlocked await pathBlocked
return route.fallback() return route.fallback()
}) })
@@ -42,8 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = [] const rejectRequests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "POST") return if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
rejectRequests.push(request.url())
}) })
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -65,9 +64,7 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click() await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest( const reply = page.waitForRequest(
(request) => (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
) )
await question.getByRole("button", { name: "Submit" }).click() await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -100,8 +97,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST") const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click() await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ reply: "once" }) expect(request.postDataJSON()).toEqual({ response: "once" })
}) })
test("restores the draft caret before typing after a request dock closes", async ({ page }) => { test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -173,7 +170,6 @@ async function mockServer(
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([ assistantMessage([
toolPart( toolPart(
id, id,
"apply_patch", "patch",
"completed", "completed",
{ files: ["src/a.ts"] }, { files: ["src/a.ts"] },
{ {
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
assistantMessage([ assistantMessage([
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: files.map((file) => file.filePath) }, { files: files.map((file) => file.filePath) },
{ metadata: { files } }, { metadata: { files } },
@@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
editPart("prt_edit"), editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }), toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"), patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart( toolPart(
"prt_question", "prt_question",
"question", "question",
@@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
]) { ]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
} }
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
}) })
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
@@ -249,7 +247,7 @@ function editPart(id: string) {
function patchPart(id: string) { function patchPart(id: string) {
return toolPart( return toolPart(
id, id,
"apply_patch", "patch",
"completed", "completed",
{ files: ["src/a.ts", "src/b.ts"] }, { files: ["src/a.ts", "src/b.ts"] },
{ {
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) => const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
) )
@@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
error: "The user dismissed this question", error: "The user dismissed this question",
}), }),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
) )
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible() await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) { for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
} }
@@ -90,7 +88,7 @@ function questionInput() {
function errorInput(tool: string) { function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" } if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] } if (tool === "patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" } if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" } if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" } if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7", id: "timeline-event-7",
}) })
@@ -100,7 +100,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a
const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7") expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined() expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
}) })
test("passes through non-event fetches", async ({ page }) => { test("passes through non-event fetches", async ({ page }) => {
@@ -1,190 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
sessionStatus: () => sessionStatus,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
sessionStatus[sourceID] = { type: "busy" }
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}

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