Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 34ed5bb399 refactor(core): simplify prompt cache diagnostics 2026-07-24 13:01:51 -04:00
Kit Langton e48306e7e1 feat(core): log prompt cache prefix changes 2026-07-24 12:55:03 -04:00
1151 changed files with 105887 additions and 74531 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.
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/plugin": minor
"@opencode-ai/sdk": minor
"@opencode-ai/client": minor
"@opencode-ai/protocol": minor
---
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
+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
if: runner.os == 'Linux'
working-directory: packages/www
working-directory: packages/docs
run: bun run check:generated
e2e:
-13
View File
@@ -1,13 +0,0 @@
import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
setup(_context: Context) {
// context.ui.toast.show({
// title: "TUI plugin discovery works",
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
// variant: "success",
// duration: 30_000,
// })
},
}
+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.
- 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.
@@ -60,7 +61,6 @@ const { a, b } = obj
### Imports
- 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 "..."`.
- 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.
+1848 -1898
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
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]
root = "./do-not-run-tests-from-root"
-118
View File
@@ -1,118 +0,0 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Add explicit data operations to that migration where generated DDL is insufficient.
- Test the migration against a populated database at the exact `dev` schema.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
generated migration must not drop the table.
## Truncate
Truncate these pre-launch V2 tables before applying schema changes:
- `event`
- `event_sequence`
- `session_message`
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
and `part` rows rather than retaining its pre-launch V2 contents.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
before migrated history. The `event` table remains empty.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Verification
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
credentials, permissions, shares, and workspaces. After migration, it should verify:
- Preserved rows and encoded values remain unchanged.
- Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
- Dropped tables no longer exist.
- New tables exist and are empty.
- The final schema has no ungenerated changes.
+1 -1
View File
@@ -15,6 +15,6 @@
"@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "catalog:",
"@opencode-ai/sdk": "1.18.5"
"@opencode-ai/sdk": "workspace:*"
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
}
}
+8 -9
View File
@@ -33,12 +33,13 @@
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/sdk/js",
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.101",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -50,7 +51,6 @@
"@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
@@ -69,7 +69,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "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",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -124,7 +124,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
@@ -152,8 +152,7 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
"@types/node": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -167,7 +166,7 @@
"@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",
"@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"
}
}
+3 -3
View File
@@ -46,7 +46,7 @@ const response = yield * LLMClient.generate(request)
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
@@ -138,13 +138,13 @@ packages/ai/src/
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
errors.ts error reasons, LLMError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.stream/generate
client.ts Route.make + LLMClient.prepare/stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
+5 -6
View File
@@ -196,6 +196,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
@@ -206,9 +207,7 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
### 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.
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.
`"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.
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.
@@ -236,7 +235,7 @@ cache: {
### 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
LLM.request({
@@ -252,8 +251,8 @@ LLM.request({
| Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
| Anthropic Messages | emits up to 3 `cache_control` markers (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) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
+1 -1
View File
@@ -568,7 +568,7 @@ App boundary = explicit durable-config -> typed-provider call
calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.stream` / `generate` to read
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels.
+32 -2
View File
@@ -50,6 +50,18 @@ const request = LLM.request({
},
})
// `http` is intentionally not needed for normal calls. This shows the shape for
// newly released provider fields before they deserve a typed provider option.
const rawOverlayExample = LLM.request({
model,
prompt: "Show the final HTTP overlay shape.",
http: {
body: { metadata: { example: "tutorial" } },
headers: { "x-opencode-tutorial": "1" },
query: { debug: "1" },
},
})
// 3. `generate` sends the request and collects the event stream into one
// response object. `response.text` is the collected text output.
const generateOnce = Effect.gen(function* () {
@@ -210,15 +222,33 @@ const FakeEcho = {
}),
}
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
// body conversion, validation, endpoint, auth, and HTTP construction without
// sending anything over the network.
const inspectFakeProvider = Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: FakeEcho.configure().model("tiny-echo"),
prompt: "Show me the provider pipeline.",
}),
)
console.log("\n== fake provider prepare ==")
console.log("route:", prepared.route)
console.log("body:", Formatter.formatJson(prepared.body, { space: 2 }))
})
// Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example.
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
// or tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () {
// yield* generateOnce
// yield* inspectFakeProvider
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
// yield* streamText
// yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
-1
View File
@@ -15,7 +15,6 @@
],
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
+25 -61
View File
@@ -2,31 +2,32 @@
// the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest.
//
// The default `"auto"` shape places breakpoints at the last tool definition,
// the first and last distinct system parts, and the conversation tail. This
// exposes reusable tool, base-agent, project, and session prefixes while
// advancing the tail after each tool result keeps the previous cache entry
// within Anthropic's 20-block lookback during long agent turns.
// The default `"auto"` shape places one breakpoint at the last tool definition,
// one at the last system part, and one at the latest user message. This
// matches what production agent harnesses (LangChain's caching middleware,
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// 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
// count against the four-breakpoint budget; auto only fills remaining slots.
// Manual `cache: CacheHint` placements on individual parts are preserved
// this function only fills gaps the caller left empty.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = {
tools: true,
system: true,
messages: { tail: 1 },
messages: "latest-user-message",
}
const NONE: CachePolicyObject = {}
const BREAKPOINT_CAP = 4
// Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// 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.
// - object form → exactly what the caller asked for.
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 =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
interface Budget {
remaining: number
}
const markLastTool = (
tools: ReadonlyArray<ToolDefinition>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<ToolDefinition> => {
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache || budget.remaining === 0) return tools
budget.remaining -= 1
if (tools[last]!.cache) return tools
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
let changed = false
const next = system.map((part, index) => {
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
budget.remaining -= 1
changed = true
return { ...part, cache: hint }
})
return changed ? next : system
const last = system.length - 1
if (system[last]!.cache) return system
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
}
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
// the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too.
const markMessageAt = (
messages: ReadonlyArray<Message>,
index: number,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages
const target = messages[index]!
if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]!
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
budget.remaining -= 1
if ("cache" in existing && existing.cache) return messages
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long
@@ -105,42 +86,25 @@ const markMessages = (
messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint,
budget: Budget,
): ReadonlyArray<Message> => {
if (messages.length === 0) return messages
if (strategy === "latest-user-message")
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
const start = Math.max(0, messages.length - strategy.tail)
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
}
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 => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const system = policy.system ? markLastSystem(request.system, hint) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
+11 -16
View File
@@ -9,28 +9,25 @@ import {
LLMRequest,
LLMResponse,
Message,
Model,
SystemPart,
ToolChoice,
ToolDefinition,
type ContentPart,
type ModelProviderOptions,
} from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput<SelectedModel extends Model = Model> = Omit<
export type RequestInput = Omit<
ConstructorParameters<typeof LLMRequest>[0],
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
"system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & {
readonly model: SelectedModel
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>>
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
readonly http?: HttpOptions.Input
}
@@ -38,7 +35,7 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => {
export const request = (input: RequestInput) => {
const {
system: requestSystem,
prompt,
@@ -66,7 +63,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice">
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
export class GenerateObjectResponse<T> {
constructor(
@@ -83,13 +80,11 @@ export class GenerateObjectResponse<T> {
}
}
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
export interface GenerateObjectOptions<S extends ToolSchema<any>> extends GenerateObjectBase {
readonly schema: S
}
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
export interface GenerateObjectDynamicOptions extends GenerateObjectBase {
/** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema
}
@@ -142,11 +137,11 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
* the schema is only available at runtime (MCP, plugin manifests). Caller validates.
*/
export function generateObject<const SelectedModel extends Model, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedModel>,
export function generateObject<S extends ToolSchema<any>>(
options: GenerateObjectOptions<S>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError>
export function generateObject<const SelectedModel extends Model>(
options: GenerateObjectDynamicOptions<SelectedModel>,
export function generateObject(
options: GenerateObjectDynamicOptions,
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
+39 -98
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -8,10 +7,8 @@ import { Protocol } from "../route/protocol"
import {
LLMError,
LLMEvent,
mergeJsonRecords,
Usage,
type CacheHint,
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
@@ -20,6 +17,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -235,27 +233,12 @@ const AnthropicBodyFields = {
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_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)],
)
const AnthropicUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
})
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.Struct({
@@ -305,12 +288,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
readonly reason: FinishReasonDetails
readonly providerMetadata?: ProviderMetadata
}
readonly lifecycle: Lifecycle.State
}
@@ -425,7 +403,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
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
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
@@ -436,7 +414,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Tool.Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
@@ -682,8 +660,9 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// `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
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are included in `output_tokens`; newer responses also
// expose that subset through `output_tokens_details.thinking_tokens`.
// thinking tokens are *not* broken out by Anthropic — they're billed as
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
// `outputTokens` carries the combined total.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
@@ -696,7 +675,6 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage },
})
@@ -715,18 +693,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
const outputTokens = right.outputTokens ?? left.outputTokens
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
return new Usage({
inputTokens,
outputTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic:
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
anthropic: {
...left.providerMetadata?.["anthropic"],
...right.providerMetadata?.["anthropic"],
},
},
})
}
@@ -785,10 +763,6 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index),
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",
}),
},
@@ -803,31 +777,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 id = `text-${event.index ?? 0}`
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
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,
]
}
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 },
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
},
events,
]
@@ -836,7 +799,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
// payload as `redactedData` metadata (same model as Vercel's
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
if (block.type === "redacted_thinking" && block.data !== undefined) {
if (block.type === "redacted_thinking" && block.data) {
const events: LLMEvent[] = []
return [
{
@@ -884,13 +847,18 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
const events: LLMEvent[] = []
return [
{
...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
}
@@ -921,53 +889,31 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const signature = state.reasoningSignatures[event.index]
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : anthropicMetadata({ signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
delete reasoningSignatures[event.index]
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
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 lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
usage: state.usage,
providerMetadata: state.pendingFinish?.providerMetadata,
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
: undefined,
})
return [{ ...state, lifecycle }, events]
return [{ ...state, lifecycle, usage }, events]
}
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
@@ -992,7 +938,6 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(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_stop") return Effect.succeed(onMessageStop(state))
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -1013,11 +958,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
step,
},
})
+2 -2
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -17,6 +16,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
} from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
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 media: GeminiInlineDataPart[] = []
for (const item of content) {
+33 -154
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
@@ -15,6 +14,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -55,9 +55,6 @@ const OpenResponsesOutputText = Schema.Struct({
text: Schema.String,
})
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -89,14 +86,10 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
export const InputItem = Schema.Union([
const OpenResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
}),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
@@ -111,14 +104,7 @@ export const InputItem = Schema.Union([
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
}
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
@@ -149,7 +135,7 @@ export const ToolChoice = Schema.Union([
// transports in sync without a destructure-and-strip dance.
export const coreFields = {
model: Schema.String,
input: Schema.Array(InputItem),
input: Schema.Array(OpenResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
@@ -181,12 +167,7 @@ export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
@@ -220,7 +201,6 @@ export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
@@ -253,7 +233,6 @@ export interface Extension {
readonly media: ProviderShared.ValidatedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -265,9 +244,6 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
@@ -371,7 +347,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
item: Content,
item: ToolContent,
request: LLMRequest,
extension: Extension,
) {
@@ -392,14 +368,14 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const system: LoweredInputItem[] =
const system: OpenResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const input: OpenResponsesInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -431,27 +407,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase =
ProviderShared.isRecord(metadata)
? messagePhase(metadata.phase, extension)
: undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
)
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
content.splice(0, content.length)
}
for (const part of message.content) {
@@ -496,7 +452,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
@@ -552,9 +508,9 @@ const lowerOptions = (request: LLMRequest) => {
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
request: LLMRequest,
extension: Extension,
extension: Extension = BASE,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
@@ -580,31 +536,23 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
}
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
// Stream Parsing
// =============================================================================
// Responses APIs report `input_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
// `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
return new Usage({
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
providerMetadata: { [providerMetadataKey]: usage },
@@ -640,30 +588,24 @@ const NO_EVENTS: StepResult["1"] = []
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
events,
]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.messageItems.has(id)) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "reasoning-0"
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
@@ -694,18 +636,6 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
},
NO_EVENTS,
]
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -862,28 +792,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
messageItems,
messagePhases,
},
events,
] satisfies StepResult
}
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
@@ -983,41 +892,19 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
}
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, event, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
return Effect.succeed(onReasoningDelta(state, event))
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
return Effect.succeed(onReasoningDone(state, event))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
if (event.type === "response.reasoning_summary_part.done")
return event.item_id
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
return Effect.succeed(onReasoningSummaryPartDone(state, event))
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
@@ -1039,18 +926,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
if (value === "commentary" || value === "final_answer") return value
return extension.messagePhase?.(value)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
+6 -9
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -18,6 +17,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
} from "../schema"
import { classifyProviderFailure } from "../provider-error"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -131,7 +131,6 @@ const OpenAIChatUsage = Schema.Struct({
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
@@ -335,7 +334,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
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)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const files = content.filter((item) => item.type === "file")
@@ -454,22 +453,20 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
// a `reasoning_tokens` subset. We pass the inclusive totals through and
// derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
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 nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
+3 -18
View File
@@ -35,18 +35,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -70,7 +60,6 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
const extension = {
id: ADAPTER,
name: NAME,
messagePhase: (value: unknown) => (value === null ? null : undefined),
lowerMedia: ({ part, media, request }) => {
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
return {
@@ -113,7 +102,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
})
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
const body = yield* OpenResponses.fromRequest(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
)
@@ -219,13 +208,9 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
return OpenResponses.step(state, event)
+2 -2
View File
@@ -1,5 +1,4 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http"
@@ -10,6 +9,7 @@ import {
type ContentPart,
type LLMRequest,
type MediaPart,
type ToolFileContent,
type TextPart,
type ToolResultPart,
} 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
})
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)
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
+7 -10
View File
@@ -14,17 +14,14 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
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 => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
return started
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
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 = (
+2 -1
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
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({
...common,
status: input.status,
@@ -145,6 +145,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
)
+3 -6
View File
@@ -1,4 +1,4 @@
import type { Model, ProviderOptions } from "./schema"
import type { Model } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly headers?: Readonly<Record<string, string>>
@@ -9,11 +9,8 @@ export interface Settings extends Readonly<Record<string, unknown>> {
}
}
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options>
export interface Definition<ProviderSettings extends Settings = Settings> {
readonly model: (modelID: string, settings: ProviderSettings) => Model
}
export * as ProviderPackage from "./provider-package"
@@ -47,7 +47,7 @@ export const configure = (input: Config) => {
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -57,10 +57,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
+1 -4
View File
@@ -52,10 +52,7 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
+6 -14
View File
@@ -99,14 +99,10 @@ export const configure = (input: Config) => {
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
return {
id,
@@ -137,12 +133,8 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).chat(modelID)
export const model = responsesModel
+4 -10
View File
@@ -4,7 +4,6 @@ import { Auth } from "../route/auth"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
@@ -21,11 +20,10 @@ type GatewayURL = AtLeastOne<{
}
export type AIGatewayOptions = GatewayURL &
Omit<RouteDefaultsInput, "providerOptions"> &
RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret
readonly providerOptions?: OpenAIProviderOptionsInput
}
type WorkersAIURL = AtLeastOne<{
@@ -33,11 +31,7 @@ type WorkersAIURL = AtLeastOne<{
readonly baseURL: string
}>
export type WorkersAIOptions = WorkersAIURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
@@ -104,7 +98,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
})
return {
id: aiGatewayID,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureAIGateway,
}
}
@@ -117,7 +111,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
})
return {
id: workersAIID,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureWorkersAI,
}
}
+2 -4
View File
@@ -50,11 +50,9 @@ export const configure = (options: ModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(modelID, defaults(options)))
.model<OpenAIProviderOptionsInput>({ id: modelID })
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
return {
id,
model: (modelID: string | ModelID) =>
@@ -1,9 +1,8 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("google-vertex")
@@ -12,7 +11,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -21,7 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly providerOptions?: ProviderOptions
}
const route = OpenAICompatibleChat.route.with({
@@ -58,7 +56,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -68,7 +66,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
@@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -101,10 +101,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
@@ -1,9 +1,8 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
export const id = ProviderID.make("google-vertex")
@@ -12,7 +11,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -21,7 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
readonly providerOptions?: ProviderOptions
}
const route = OpenAICompatibleResponses.route.with({
@@ -60,7 +58,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -70,10 +68,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
+2 -6
View File
@@ -77,8 +77,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
export const configure = (input: Config = {}) => {
return {
id,
model: (modelID: string | ModelID) =>
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
configure,
}
}
@@ -87,10 +86,7 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
+2 -2
View File
@@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => {
})
return {
id,
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
image,
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
@@ -36,7 +36,7 @@ export const configure = (input: Config) => {
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -46,10 +46,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
@@ -4,15 +4,13 @@ import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("openai-compatible")
type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
type GenericModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -21,10 +19,9 @@ export interface Settings extends ProviderPackage.Settings {
readonly provider?: string
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type FamilyModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const routes = [OpenAICompatibleChat.route]
@@ -40,8 +37,7 @@ export const configure = (input: GenericModelOptions) => {
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) =>
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
configure,
}
}
@@ -67,7 +63,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+6 -13
View File
@@ -86,15 +86,10 @@ export const configure = (input: Config = {}) => {
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
@@ -137,17 +132,15 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
const configured = configure(config(settings))
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).chat(modelID)
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
+1 -1
View File
@@ -107,7 +107,7 @@ export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
+3 -5
View File
@@ -5,14 +5,12 @@ import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("xai")
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type ModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
@@ -44,8 +42,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
+29 -11
View File
@@ -10,7 +10,7 @@ import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, ProtocolID, ProviderOptions } from "../schema"
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import {
GenerationOptions,
HttpOptions,
@@ -20,6 +20,7 @@ import {
ModelLimits,
LLMError as LLMErrorClass,
LLMEvent,
PreparedRequest,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -45,7 +46,7 @@ export interface Route<Body, Prepared = unknown> {
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly model: (input: RouteMappedModelInput) => Model
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
@@ -92,12 +93,12 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make<Options>({
return Model.make({
...mapped,
provider,
route,
@@ -141,6 +142,17 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
/**
* Compile a request through protocol body construction, validation, and HTTP
* preparation without sending it. Returns the prepared request including the
* provider-native body.
*
* Pass a `Body` type argument to statically expose the route's body
* shape (e.g. `prepare<OpenAIChatBody>(...)`) the runtime body is
* identical, so this is a type-level assertion the caller makes about which
* route the request will resolve to.
*/
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -284,8 +296,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults),
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
model: (input) => makeRouteModel(route, input),
prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
@@ -359,6 +370,9 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
// `compile` is the important boundary: it turns a common `LLMRequest` into a
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
@@ -376,17 +390,17 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
}
})
/** @internal Test-only projection of the execution compiler; not exported from package barrels. */
export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) {
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
const compiled = yield* compile(request)
return {
return new PreparedRequest({
id: compiled.request.id ?? "request",
route: compiled.route.id,
protocol: compiled.route.protocol,
model: compiled.request.model,
body: compiled.body,
metadata: { transport: compiled.route.transport.id },
}
})
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
@@ -408,6 +422,9 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export const prepare = <Body = unknown>(request: LLMRequest) =>
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
@@ -436,7 +453,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
})
return Service.of({ stream, generate: generateWith(stream) })
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}),
)
@@ -445,6 +462,7 @@ export const Route = { make } as const
export const LLMClient = {
Service,
layer,
prepare,
stream,
generate,
} as const
+5 -2
View File
@@ -1,5 +1,4 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
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
* 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)),
}) {}
+28 -4
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
@@ -39,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic's `outputTokens` includes extended thinking. Newer responses
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
* to `reasoningTokens`; older responses leave it undefined.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total a documented limitation of the Anthropic API.
*
* `providerMetadata` always carries the provider's raw usage payload
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
@@ -313,6 +314,29 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
id: Schema.String,
route: RouteID,
protocol: ProtocolID,
model: ModelSchema,
body: Schema.Unknown,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
/**
* A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
* on `LLMClient.prepare<Body>(...)` when the caller knows which route their
* request will resolve to and wants its native shape statically exposed
* (debug UIs, request previews, plan rendering).
*
* The runtime body is identical the route still emits `body: unknown` so
* this is a type-level assertion the caller makes about what they expect to
* find. The prepare runtime does not validate the assertion.
*/
export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
readonly body: Body
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.textDelta)
+7 -5
View File
@@ -1,5 +1,5 @@
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 { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
@@ -40,6 +40,8 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export { ToolContent, ToolFileContent, ToolTextContent }
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
@@ -61,7 +63,7 @@ export const ToolResultValue = Object.assign(
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(Tool.Content),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
@@ -77,16 +79,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput {
readonly structured: unknown
readonly content: ReadonlyArray<Tool.Content>
readonly content: ReadonlyArray<ToolContent>
}
export const ToolOutput = Object.assign(
Schema.Struct({
structured: Schema.Unknown,
content: Schema.Array(Tool.Content),
content: Schema.Array(ToolContent),
}).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 => {
switch (result.type) {
case "json":
+11 -14
View File
@@ -178,8 +178,7 @@ export namespace ModelCompatibility {
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
}
export class Model<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
export class Model {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
@@ -194,8 +193,8 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility
}
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) {
return new Model<Options>({
static make(input: Model.Input) {
return new Model({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -204,7 +203,7 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
})
}
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput {
static input(model: Model): Model.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -214,9 +213,9 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
}
}
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) {
static update(model: Model, patch: Partial<Model.Input>) {
if (Object.keys(patch).length === 0) return model
return Model.make<Options>({
return Model.make({
...Model.input(model),
...patch,
})
@@ -242,8 +241,6 @@ export namespace Model {
export type ModelInput = Model.Input
export type ModelProviderOptions<SelectedModel> = SelectedModel extends Model<infer Options> ? Options : never
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
@@ -254,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places
// breakpoints at the last tool definition, the first and last distinct system
// parts, and the conversation tail. The rolling message breakpoint keeps a
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
// tool loops.
// usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoint at the last tool definition, one at the last system part, and one
// at the latest user message. The combination of provider invalidation
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// lookback means three trailing breakpoints reliably cover the static prefix.
//
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices.
-156
View File
@@ -1,156 +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({
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(
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)),
),
)
+6 -6
View File
@@ -1,7 +1,7 @@
import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import type {
ToolCallPart,
ToolContent,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
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
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
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
}
@@ -151,7 +151,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
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
}): AnyExecutableTool
export function make(config: {
@@ -159,7 +159,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
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
}): AnyTool
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
}
const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown,
callID: ToolCallPart["id"],
+3 -3
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { compileRequest } from "../src/route/client"
import { Model } from "../src/schema"
import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
@@ -140,7 +139,8 @@ describe("llm route", () => {
it.effect("selects routes by model route value", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const llm = yield* LLMClient.Service
const prepared = yield* llm.prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
)
@@ -173,7 +173,7 @@ describe("llm route", () => {
framing: fakeFraming,
})
const prepared = yield* compileRequest(
const prepared = yield* (yield* LLMClient.Service).prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
)
+20 -81
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src"
import { Auth } from "../src/route"
import { compileRequest } from "../src/route/client"
import { Auth, LLMClient } from "../src/route"
import { AmazonBedrock } from "../src/providers"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as Gemini from "../src/protocols/gemini"
@@ -32,7 +31,7 @@ const geminiModel = Gemini.route
describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "You are concise.",
@@ -40,8 +39,8 @@ describe("applyCachePolicy", () => {
}),
)
// A single system block is both the first and last boundary, so the auto
// policy deduplicates it and still marks the conversation tail.
// No explicit cache field → auto policy fires → last system part + latest
// user message both get cache_control markers.
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
@@ -49,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* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
system: "Sys A",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [
Message.user("first user"),
@@ -70,10 +66,7 @@ describe("applyCachePolicy", () => {
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
],
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
messages: [
{ role: "user", content: [{ type: "text", text: "first user" }] },
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
@@ -88,7 +81,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: openaiModel,
system: "Sys",
@@ -107,7 +100,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: geminiModel,
system: "Sys",
@@ -124,13 +117,10 @@ describe("applyCachePolicy", () => {
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: bedrockModel,
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
cache: "auto",
@@ -141,12 +131,7 @@ describe("applyCachePolicy", () => {
toolConfig: {
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
},
system: [
{ text: "Base agent" },
{ cachePoint: { type: "default" } },
{ text: "Project instructions" },
{ cachePoint: { type: "default" } },
],
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
messages: [
{ role: "user", content: [{ text: "first user" }] },
{ role: "assistant", content: [{ text: "reply" }] },
@@ -158,7 +143,7 @@ describe("applyCachePolicy", () => {
it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -177,7 +162,7 @@ describe("applyCachePolicy", () => {
it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -196,7 +181,7 @@ describe("applyCachePolicy", () => {
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: [
@@ -208,61 +193,15 @@ describe("applyCachePolicy", () => {
}),
)
const body = prepared.body as {
system: Array<{ text: string; cache_control?: unknown }>
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
}
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
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* compileRequest(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()
}),
)
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -279,7 +218,7 @@ describe("applyCachePolicy", () => {
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
@@ -297,7 +236,7 @@ describe("applyCachePolicy", () => {
it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
-2
View File
@@ -19,7 +19,6 @@ import {
OpenResponses,
} from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing"
describe("public exports", () => {
test("root exposes app-facing runtime APIs", () => {
@@ -29,7 +28,6 @@ describe("public exports", () => {
expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make)
expect(TestLLM.layer).toBeFunction()
})
test("route barrel exposes route-authoring APIs", () => {
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
import { Schema } from "effect"
import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
readonly [key: string]: unknown
readonly mode?: "fast" | "thorough"
}
type ExampleProviderOptions = ProviderOptions & {
readonly example?: ExampleOptions
}
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } })
.model<ExampleProviderOptions>({ id: "example" })
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Known provider options preserve their value types.
providerOptions: { example: { mode: "slow" } },
})
LLM.generateObject({
model,
prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } },
})
LLM.generateObject({
model,
prompt: "Hello",
jsonSchema: { type: "object" },
// @ts-expect-error Dynamic object generation uses the selected model's provider options.
providerOptions: { example: { mode: false } },
})
declare const generic: Model
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: ModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options
@@ -4,7 +4,6 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route"
import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks"
@@ -45,7 +44,7 @@ describe("request option precedence", () => {
})
})
it.effect("compiles bodies with route defaults, model defaults, and call options in order", () =>
it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
@@ -60,7 +59,7 @@ describe("request option precedence", () => {
providerOptions: { openai: { reasoningEffort: "medium" } },
},
})
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "Say hello.",
@@ -142,7 +141,7 @@ describe("request option precedence", () => {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Say hello.",
@@ -165,8 +164,10 @@ describe("request option precedence", () => {
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest(
const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none" }),
)
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
)
-6
View File
@@ -58,12 +58,6 @@ describe("provider error classification", () => {
).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", () => {
expect(
[
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { AnthropicCompatible } from "../../src/providers"
const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Anthropic effort must be a string.
providerOptions: { anthropic: { effort: 1 } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { Anthropic } from "../../src/providers"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { thinking: { type: "adaptive" } } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Anthropic thinking modes are a fixed union.
providerOptions: { anthropic: { thinking: { type: "automatic" } } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { Azure } from "../../src/providers"
const model = Azure.configure({ resourceName: "example" }).responses("deployment")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Azure OpenAI store must be boolean.
providerOptions: { openai: { store: "false" } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { CloudflareWorkersAI } from "../../src/providers"
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
providerOptions: { openai: { promptCacheKey: 1 } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { GitHubCopilot } from "../../src/providers"
const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Copilot reasoning summaries use the OpenAI union.
providerOptions: { openai: { reasoningSummary: "full" } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { GoogleVertexChat } from "../../src/providers"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { serviceTier: "priority" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { openai: { serviceTier: "premium" } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { GoogleVertexMessages } from "../../src/providers"
const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "medium" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Anthropic effort must be a string.
providerOptions: { anthropic: { effort: false } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { GoogleVertexResponses } from "../../src/providers"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { textVerbosity: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
providerOptions: { openresponses: { textVerbosity: "verbose" } },
})
@@ -1,17 +0,0 @@
import { LLM } from "../../src"
import { GoogleVertex } from "../../src/providers"
const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-2.5-pro")
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { includeThoughts: true } } },
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Gemini includeThoughts must be boolean.
providerOptions: { gemini: { thinkingConfig: { includeThoughts: "yes" } } },
})
@@ -1,17 +0,0 @@
import { LLM } from "../../src"
import { Google } from "../../src/providers"
const model = Google.provider.model("gemini-2.5-pro")
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { OpenAICompatibleResponses } from "../../src/providers"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { reasoningSummary: "detailed" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Open Responses reasoning summaries use a fixed union.
providerOptions: { openresponses: { reasoningSummary: "full" } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { OpenAICompatible } from "../../src/providers"
const model = OpenAICompatible.deepseek.model("deepseek-chat")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenAI-compatible store must be boolean.
providerOptions: { openai: { store: "false" } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { OpenAI } from "../../src/providers"
const model = OpenAI.responses("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { OpenRouter } from "../../src/providers"
const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenRouter usage must be boolean or an option record.
providerOptions: { openrouter: { usage: "yes" } },
})
@@ -1,13 +0,0 @@
import { LLM } from "../../src"
import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: true } },
})
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
@@ -24,39 +24,6 @@ const cacheRequest = LLM.request({
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({
prefix: "anthropic-messages-cache",
provider: "anthropic",
@@ -83,28 +50,4 @@ describe("Anthropic Messages cache recorded", () => {
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)
}),
)
})
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
import { it } from "../lib/effect"
@@ -45,7 +44,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro
describe("Anthropic Messages route", () => {
it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({
model: "claude-sonnet-4-5",
@@ -60,7 +59,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
@@ -77,17 +76,17 @@ describe("Anthropic Messages route", () => {
it.effect("normalizes enabled and disabled thinking settings", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
}),
)
const legacy = yield* compileRequest(
const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
}),
)
const disabled = yield* compileRequest(
const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}),
@@ -101,7 +100,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
}),
@@ -113,7 +112,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [
@@ -138,7 +137,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [
@@ -165,7 +164,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model: opus48,
messages: [
@@ -182,7 +181,7 @@ describe("Anthropic Messages route", () => {
it.effect("falls back for unsupported native chronological system update placement", () =>
Effect.gen(function* () {
expect(
(yield* compileRequest(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
@@ -197,11 +196,12 @@ describe("Anthropic Messages route", () => {
},
])
expect(
(yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" })))
.body.messages,
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
)).body.messages,
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
expect(
(yield* compileRequest(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model: opus48,
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
@@ -223,7 +223,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model: opus48,
messages: [
@@ -242,7 +242,7 @@ describe("Anthropic Messages route", () => {
it.effect("prepares tool call and tool result messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result",
model,
@@ -273,7 +273,7 @@ describe("Anthropic Messages route", () => {
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_choice_none",
model,
@@ -303,7 +303,7 @@ describe("Anthropic Messages route", () => {
// not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result_image",
model,
@@ -335,7 +335,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers single-image tool-result content as a structured image block", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_result_image_only",
model,
@@ -360,7 +360,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
@@ -384,7 +384,7 @@ describe("Anthropic Messages route", () => {
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
continuationRequest({
id: "req_native_continuation_anthropic",
model,
@@ -428,7 +428,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
@@ -447,7 +447,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
@@ -506,7 +506,6 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" },
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
@@ -519,197 +518,6 @@ describe("Anthropic Messages route", () => {
}),
)
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* compileRequest(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(
@@ -771,7 +579,7 @@ describe("Anthropic Messages route", () => {
),
),
)
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
messages: [
@@ -821,7 +629,6 @@ describe("Anthropic Messages route", () => {
delta: { stop_reason: "model_context_window_exceeded" },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
),
),
),
@@ -839,7 +646,6 @@ describe("Anthropic Messages route", () => {
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
@@ -858,7 +664,6 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
@@ -1044,7 +849,6 @@ describe("Anthropic Messages route", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
@@ -1108,7 +912,6 @@ describe("Anthropic Messages route", () => {
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
@@ -1131,7 +934,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_round_trip",
model,
@@ -1182,7 +985,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_unknown_server_tool",
model,
@@ -1259,7 +1062,7 @@ describe("Anthropic Messages route", () => {
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
@@ -1275,7 +1078,7 @@ describe("Anthropic Messages route", () => {
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
tools: [
@@ -1316,7 +1119,7 @@ describe("Anthropic Messages route", () => {
it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: [
@@ -1342,7 +1145,7 @@ describe("Anthropic Messages route", () => {
it.effect("spends breakpoint budget on tools before system before messages", () =>
Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
tools: [
@@ -13,7 +13,6 @@ import {
ToolDefinition,
} from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { it } from "../lib/effect"
@@ -102,7 +101,7 @@ const baseRequest = LLM.request({
describe("Bedrock Converse route", () => {
it.effect("prepares Converse target with system, inference config, and messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest)
const prepared = yield* LLMClient.prepare(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -115,7 +114,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLMRequest.update(baseRequest, {
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
}),
@@ -130,14 +129,14 @@ describe("Bedrock Converse route", () => {
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest)
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -154,7 +153,7 @@ describe("Bedrock Converse route", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
@@ -188,7 +187,7 @@ describe("Bedrock Converse route", () => {
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
@@ -218,7 +217,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_history",
model,
@@ -257,7 +256,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_image",
model,
@@ -492,7 +491,7 @@ describe("Bedrock Converse route", () => {
providerMetadata: { bedrock: { signature: "sig_1" } },
})
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
@@ -547,7 +546,9 @@ describe("Bedrock Converse route", () => {
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
@@ -638,7 +639,7 @@ describe("Bedrock Converse route", () => {
text: "",
providerMetadata: { bedrock: { redactedData } },
})
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
@@ -753,7 +754,7 @@ describe("Bedrock Converse route", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
@@ -763,7 +764,7 @@ describe("Bedrock Converse route", () => {
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_cache",
model,
@@ -795,7 +796,7 @@ describe("Bedrock Converse route", () => {
it.effect("does not emit cachePoint when no cache hint is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest)
const prepared = yield* LLMClient.prepare(baseRequest)
expect(prepared.body).toMatchObject({
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
@@ -805,7 +806,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image media into Bedrock image blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_image",
model,
@@ -842,7 +843,7 @@ describe("Bedrock Converse route", () => {
it.effect("base64-encodes Uint8Array image bytes", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_image_bytes",
model,
@@ -864,7 +865,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_doc",
model,
@@ -896,7 +897,7 @@ describe("Bedrock Converse route", () => {
it.effect("requires names for document media", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
@@ -909,7 +910,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
@@ -935,10 +936,9 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({
@@ -987,7 +987,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_bad_image",
model,
@@ -1001,7 +1001,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported document media types", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_bad_doc",
model,
@@ -1016,7 +1016,7 @@ describe("Bedrock Converse route", () => {
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: [{ type: "text", text: "system", cache }],
@@ -1033,7 +1033,7 @@ describe("Bedrock Converse route", () => {
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
@@ -1065,7 +1065,7 @@ describe("Bedrock Converse route", () => {
it.effect("drops cachePoint markers past the 4-per-request cap", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: [
+5 -5
View File
@@ -3,7 +3,7 @@ import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { compileRequest } from "../../src/route/client"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -34,7 +34,7 @@ describe("Cloudflare", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-ai-gateway")
expect(prepared.body).toMatchObject({
@@ -129,7 +129,7 @@ describe("Cloudflare", () => {
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
])
@@ -180,7 +180,7 @@ describe("Cloudflare", () => {
it.effect("allows a fully configured baseURL override", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: CloudflareAIGateway.configure({
baseURL: "https://gateway.proxy.test/v1/custom/compat",
@@ -208,7 +208,7 @@ describe("Cloudflare", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-workers-ai")
expect(prepared.body).toMatchObject({
+13 -14
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared"
import { it } from "../lib/effect"
@@ -27,7 +26,7 @@ const request = LLM.request({
describe("Gemini route", () => {
it.effect("prepares Gemini target", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
@@ -39,12 +38,12 @@ describe("Gemini route", () => {
it.effect("normalizes Gemini thinking options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
const filtered = yield* compileRequest(
const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
@@ -60,7 +59,7 @@ describe("Gemini route", () => {
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -76,7 +75,7 @@ describe("Gemini route", () => {
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
@@ -144,7 +143,7 @@ describe("Gemini route", () => {
it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
model,
messages: [
@@ -189,7 +188,7 @@ describe("Gemini route", () => {
it.effect("strips matching data URLs to raw base64 inlineData", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
model,
messages: [
@@ -230,7 +229,7 @@ describe("Gemini route", () => {
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -239,7 +238,7 @@ describe("Gemini route", () => {
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
@@ -257,7 +256,7 @@ describe("Gemini route", () => {
it.effect("keeps tools and sends function calling mode NONE", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_choice_none",
model,
@@ -277,7 +276,7 @@ describe("Gemini route", () => {
it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_schema_patch",
model,
@@ -458,7 +457,7 @@ describe("Gemini route", () => {
response.events.findIndex((event) => event.type === "tool-call"),
)
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
model,
messages: [
@@ -692,7 +691,7 @@ describe("Gemini route", () => {
it.effect("rejects unsupported assistant media content", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
@@ -4,7 +4,6 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { deltaChunk, finishChunk } from "../lib/openai-chunks"
@@ -183,7 +182,7 @@ describe("Google Vertex providers", () => {
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
@@ -5,7 +5,6 @@ import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
@@ -85,7 +84,9 @@ for (const item of cases) {
),
).toBe(true)
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
+51 -33
View File
@@ -18,7 +18,6 @@ import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
@@ -43,7 +42,11 @@ const request = LLM.request({
describe("OpenAI Chat route", () => {
it.effect("prepares OpenAI Chat payload", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
// Pass the OpenAIChat payload type so `prepared.body` is statically
// typed to the route's native shape — the assertions below read field
// names without `unknown` casts.
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(request)
const _typed: { readonly model: string; readonly stream: true } = prepared.body
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
@@ -61,7 +64,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -84,7 +87,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -102,7 +105,7 @@ describe("OpenAI Chat route", () => {
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [
@@ -128,7 +131,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
@@ -141,7 +144,7 @@ describe("OpenAI Chat route", () => {
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
@@ -156,7 +159,7 @@ describe("OpenAI Chat route", () => {
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "think",
@@ -250,7 +253,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares assistant tool-call and tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
@@ -288,7 +291,7 @@ describe("OpenAI Chat route", () => {
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -308,7 +311,7 @@ describe("OpenAI Chat route", () => {
it.effect("continues image tool results as vision input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -352,7 +355,7 @@ describe("OpenAI Chat route", () => {
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -402,7 +405,7 @@ describe("OpenAI Chat route", () => {
it.effect("aggregates consecutive tool images with a following system update", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -443,7 +446,7 @@ describe("OpenAI Chat route", () => {
it.effect("appends system updates without replacing multipart user content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -471,7 +474,7 @@ describe("OpenAI Chat route", () => {
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -480,7 +483,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
@@ -498,7 +501,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares raw and data URL image media as vision input", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
id: "req_media",
model,
@@ -525,7 +528,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers reasoning-only assistant history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
id: "req_reasoning",
model,
@@ -547,7 +550,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
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 },
}),
)
@@ -555,9 +558,8 @@ describe("OpenAI Chat route", () => {
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 2,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
@@ -565,7 +567,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens: 5,
completion_tokens: 2,
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 },
},
},
@@ -616,7 +618,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: field },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
}
}),
@@ -642,7 +646,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "vendor_reasoning" },
})
const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
}),
)
@@ -685,7 +691,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
@@ -728,7 +736,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: details },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
@@ -753,7 +763,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
@@ -826,7 +838,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: [] },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
@@ -874,7 +888,9 @@ describe("OpenAI Chat route", () => {
response.events.findIndex(LLMEvent.is.textStart),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
@@ -901,7 +917,9 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
@@ -931,7 +949,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* compileRequest(
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -960,7 +978,7 @@ describe("OpenAI Chat route", () => {
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* compileRequest(
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -985,7 +1003,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* compileRequest(
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
@@ -53,7 +52,7 @@ const providerFamilies = [
describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
toolChoice: ToolChoice.make({ type: "required" }),
@@ -128,7 +127,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({
model: "deepseek-chat",
@@ -146,7 +145,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_parity",
model,
@@ -1,13 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src"
import { LLM, LLMEvent } from "../../src"
import { configure } from "../../src/providers/openai-compatible-responses"
import { OpenAI } from "../../src/providers"
import { OpenResponses } from "../../src/protocols/open-responses"
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
import { OpenAIResponses } from "../../src/protocols/openai-responses"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -24,7 +23,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
system: "You are concise.",
@@ -62,7 +61,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
).pipe(Effect.flip)
@@ -71,31 +70,6 @@ describe("Open Responses-compatible route", () => {
}),
)
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* compileRequest(
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({
@@ -103,7 +77,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
@@ -14,7 +14,6 @@ import {
Usage,
} from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as XAI from "../../src/providers/xai"
@@ -57,7 +56,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
@@ -75,7 +74,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
@@ -93,7 +92,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
@@ -110,7 +109,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
const prepared = yield* compileRequest(input)
const prepared = yield* LLMClient.prepare(input)
expect(prepared.body).toMatchObject({ service_tier: "priority" })
expect(prepared.body).not.toHaveProperty("serviceTier")
@@ -119,7 +118,7 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
)
@@ -129,7 +128,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
)
@@ -139,7 +138,7 @@ describe("OpenAI Responses route", () => {
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({
@@ -192,7 +191,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -218,7 +217,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -396,7 +395,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares function call and function output input items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result",
model,
@@ -433,7 +432,7 @@ describe("OpenAI Responses route", () => {
content: [],
structured: {},
}
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -454,7 +453,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps primitive tool errors as plain text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -470,7 +469,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps non-JSON tool errors as plain text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -488,7 +487,7 @@ describe("OpenAI Responses route", () => {
// image data is not JSON-stringified into `function_call_output.output`.
it.effect("lowers image tool-result content as structured input_image items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_image",
model,
@@ -517,7 +516,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers single-image tool-result content as structured input_image array", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_image_only",
model,
@@ -541,7 +540,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_pdf",
model,
@@ -576,7 +575,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
@@ -611,7 +610,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
@@ -634,7 +633,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
continuationRequest({
id: "req_native_continuation_openai",
model,
@@ -676,7 +675,7 @@ describe("OpenAI Responses route", () => {
it.effect("maps OpenAI provider options to Responses options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
@@ -701,7 +700,7 @@ describe("OpenAI Responses route", () => {
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "hi",
@@ -723,7 +722,7 @@ describe("OpenAI Responses route", () => {
it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "hi",
@@ -740,7 +739,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
)
@@ -750,7 +749,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
@@ -760,7 +759,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits include when no include is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
)
@@ -774,7 +773,7 @@ describe("OpenAI Responses route", () => {
// reasoningSummary: "auto" by default. Without `include`, a follow-up
// turn cannot replay reasoning state, so the facade also opts into
// `reasoning.encrypted_content` automatically.
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
@@ -789,7 +788,7 @@ describe("OpenAI Responses route", () => {
it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
@@ -803,7 +802,7 @@ describe("OpenAI Responses route", () => {
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/",
@@ -833,7 +832,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5,
output_tokens: 2,
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 },
},
},
@@ -843,9 +842,8 @@ describe("OpenAI Responses route", () => {
const usage = new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 2,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
cacheWriteInputTokens: 2,
reasoningTokens: 0,
totalTokens: 7,
providerMetadata: {
@@ -853,7 +851,7 @@ describe("OpenAI Responses route", () => {
input_tokens: 5,
output_tokens: 2,
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 },
},
},
@@ -883,119 +881,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* compileRequest(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", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
@@ -1269,7 +1154,7 @@ describe("OpenAI Responses route", () => {
it.effect("preserves assistant content order around reasoning items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_reasoning_order",
model,
@@ -1307,7 +1192,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -1329,7 +1214,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored provider-executed hosted tool results by id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -1366,7 +1251,7 @@ describe("OpenAI Responses route", () => {
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
@@ -1407,7 +1292,7 @@ describe("OpenAI Responses route", () => {
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_multi_summary_continuation",
model,
@@ -1444,7 +1329,7 @@ describe("OpenAI Responses route", () => {
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_reasoning_without_encrypted_state",
model,
@@ -1761,7 +1646,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_media",
model,
@@ -1792,7 +1677,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
@@ -1824,7 +1709,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
+6 -7
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
@@ -20,7 +19,7 @@ describe("OpenRouter", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("openrouter")
expect(prepared.body).toMatchObject({
@@ -33,7 +32,7 @@ describe("OpenRouter", () => {
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
@@ -101,7 +100,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -134,7 +133,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -159,7 +158,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
]
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -180,7 +179,7 @@ describe("OpenRouter", () => {
it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
+2 -5
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
GenerationOptions,
@@ -8,6 +7,7 @@ import {
LLMRequest,
LLMResponse,
ToolChoice,
ToolContent,
ToolOutput,
toDefinitions,
} from "../src"
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
it.effect("models canonical tool files with URIs", () =>
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({
type: "file",
@@ -539,7 +539,6 @@ describe("LLMClient tools", () => {
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
{ type: "message_stop" },
)
: sseEvents(
{ 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_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
@@ -803,7 +801,6 @@ describe("LLMClient tools", () => {
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
{ type: "content_block_stop", index: 2 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
@@ -3,8 +3,7 @@ import { Effect } from "effect"
import { LLM } from "../src"
import { OpenAIChat } from "../src/protocols"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
import { Auth } from "../src/route"
import { compileRequest } from "../src/route/client"
import { Auth, LLMClient } from "../src/route"
import { it } from "./lib/effect"
describe("tool schema projections", () => {
@@ -80,7 +79,7 @@ describe("tool schema projections", () => {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } })
const prepared = yield* compileRequest(
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "Use the tool.",
-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: "multi patch",
tool: "apply_patch",
tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
},
] as const
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
userMessage(),
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"),
],
{ completed: false },
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"apply_patch",
"patch",
"running",
{ files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } },
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"apply_patch",
"patch",
"completed",
{ files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } },
@@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
}
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"),
]
const childID = "ses_timeline_child"
@@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
await timeline.send(status("busy"), 120)
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="${todoID}"]`)).toHaveCount(0)
const regionIDs = [
"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="${todoID}"]`)).toHaveCount(0)
await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()
@@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastAssistant = assistants.at(-1)!
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}`
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
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 }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
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 page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
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,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
tool: "patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
@@ -131,7 +131,7 @@ function toolPart(
): MessagePart {
const metadata =
metadataOverride ??
(tool === "apply_patch"
(tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: 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)]
: []),
...(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
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
@@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
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 { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
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 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()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
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 directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, {}, 404)
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 === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") 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 (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))
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")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
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,
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 === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
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 { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
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 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 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}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
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"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -181,36 +180,10 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
return json(route, { data: [] })
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: {} })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -243,12 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
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 === "/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, {})
})
}

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