diff --git a/.opencode/plugins/tui/discovery-smoke.ts b/.opencode/plugins/tui/discovery-smoke.ts
index f0144918f1..fb7edaf349 100644
--- a/.opencode/plugins/tui/discovery-smoke.ts
+++ b/.opencode/plugins/tui/discovery-smoke.ts
@@ -2,15 +2,12 @@ import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
- setup(context: Context) {
- const timer = setTimeout(() => {
- context.ui.toast.show({
- title: "TUI plugin discovery works",
- message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
- variant: "success",
- duration: 30_000,
- })
- }, 1_000)
- return () => clearTimeout(timer)
+ 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,
+ // })
},
}
diff --git a/bun.lock b/bun.lock
index e09ea82a5c..4288f95e82 100644
--- a/bun.lock
+++ b/bun.lock
@@ -401,8 +401,11 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
+ "tree-sitter-bash": "0.25.0",
+ "tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
+ "web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -599,6 +602,7 @@
"zod": "catalog:",
},
"devDependencies": {
+ "@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -610,12 +614,14 @@
"typescript": "catalog:",
},
"peerDependencies": {
+ "@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
+ "@opencode-ai/theme",
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
@@ -1069,9 +1075,11 @@
},
"trustedDependencies": [
"esbuild",
+ "tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
+ "tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -5630,6 +5638,10 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
+ "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
+
+ "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
+
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6850,6 +6862,8 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
+
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
diff --git a/docs/design/v1-v2-database-migration.md b/docs/design/v1-v2-database-migration.md
new file mode 100644
index 0000000000..d03329da30
--- /dev/null
+++ b/docs/design/v1-v2-database-migration.md
@@ -0,0 +1,118 @@
+# 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.
diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md
index 6c35cafc34..ad4a625385 100644
--- a/packages/ai/AGENTS.md
+++ b/packages/ai/AGENTS.md
@@ -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.prepare
(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(...)` returns a `PreparedRequestOf`). The runtime body is identical; the generic is a type-level assertion.
+Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
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, PreparedRequest, LLMResponse
+ events.ts Usage, individual events, LLMEvent, 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.prepare/stream/generate
+ client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
diff --git a/packages/ai/README.md b/packages/ai/README.md
index 377e09306f..6d89fa317b 100644
--- a/packages/ai/README.md
+++ b/packages/ai/README.md
@@ -196,7 +196,6 @@ 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`.
diff --git a/packages/ai/example/call-sites.md b/packages/ai/example/call-sites.md
index 0b33d28c48..397e0cc09f 100644
--- a/packages/ai/example/call-sites.md
+++ b/packages/ai/example/call-sites.md
@@ -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.prepare` / `stream` / `generate` to read
+- [x] Rework `LLMClient.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.
diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts
index 7ee4abb146..3924a57dd2 100644
--- a/packages/ai/example/tutorial.ts
+++ b/packages/ai/example/tutorial.ts
@@ -50,18 +50,6 @@ 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* () {
@@ -222,33 +210,15 @@ 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, prepare, stream,
-// or tool-loop behavior without spending tokens on every example.
+// enabled at a time so the tutorial can demonstrate generate, 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))))
diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts
index 8b6f904a5b..77ec766e9a 100644
--- a/packages/ai/src/llm.ts
+++ b/packages/ai/src/llm.ts
@@ -9,25 +9,28 @@ 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 = Omit<
+export type RequestInput = Omit<
ConstructorParameters[0],
- "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
+ "model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & {
+ readonly model: SelectedModel
readonly system?: string | SystemPart | ReadonlyArray
readonly prompt?: string | ContentPart | ReadonlyArray
readonly messages?: ReadonlyArray
readonly tools?: ReadonlyArray
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
- readonly providerOptions?: ConstructorParameters[0]["providerOptions"]
+ readonly providerOptions?: NoInfer>
readonly http?: HttpOptions.Input
}
@@ -35,7 +38,7 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream
-export const request = (input: RequestInput) => {
+export const request = (input: RequestInput) => {
const {
system: requestSystem,
prompt,
@@ -63,7 +66,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
-type GenerateObjectBase = Omit
+type GenerateObjectBase = Omit, "tools" | "toolChoice">
export class GenerateObjectResponse {
constructor(
@@ -80,11 +83,13 @@ export class GenerateObjectResponse {
}
}
-export interface GenerateObjectOptions> extends GenerateObjectBase {
+export interface GenerateObjectOptions, SelectedModel extends Model = Model>
+ extends GenerateObjectBase {
readonly schema: S
}
-export interface GenerateObjectDynamicOptions extends GenerateObjectBase {
+export interface GenerateObjectDynamicOptions
+ extends GenerateObjectBase {
/** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema
}
@@ -137,11 +142,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>(
- options: GenerateObjectOptions,
+export function generateObject>(
+ options: GenerateObjectOptions,
): Effect.Effect>, LLMError>
-export function generateObject(
- options: GenerateObjectDynamicOptions,
+export function generateObject(
+ options: GenerateObjectDynamicOptions,
): Effect.Effect, LLMError>
export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
diff --git a/packages/ai/src/provider-package.ts b/packages/ai/src/provider-package.ts
index fd878a9d06..4f7dc0dcad 100644
--- a/packages/ai/src/provider-package.ts
+++ b/packages/ai/src/provider-package.ts
@@ -1,4 +1,4 @@
-import type { Model } from "./schema"
+import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly> {
readonly headers?: Readonly>
@@ -9,8 +9,11 @@ export interface Settings extends Readonly> {
}
}
-export interface Definition {
- readonly model: (modelID: string, settings: ProviderSettings) => Model
+export interface Definition<
+ ProviderSettings extends Settings = Settings,
+ Options extends ProviderOptions = ProviderOptions,
+> {
+ readonly model: (modelID: string, settings: ProviderSettings) => Model
}
export * as ProviderPackage from "./provider-package"
diff --git a/packages/ai/src/providers/anthropic-compatible.ts b/packages/ai/src/providers/anthropic-compatible.ts
index 56cc7cee32..93d1bac048 100644
--- a/packages/ai/src/providers/anthropic-compatible.ts
+++ b/packages/ai/src/providers/anthropic-compatible.ts
@@ -47,7 +47,7 @@ export const configure = (input: Config) => {
})
return {
id: ProviderID.make(provider),
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -57,7 +57,10 @@ export const provider = {
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts
index eb175ec50c..d8e57be1b3 100644
--- a/packages/ai/src/providers/anthropic.ts
+++ b/packages/ai/src/providers/anthropic.ts
@@ -52,7 +52,10 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
diff --git a/packages/ai/src/providers/azure.ts b/packages/ai/src/providers/azure.ts
index dd0691a539..d4c916a588 100644
--- a/packages/ai/src/providers/azure.ts
+++ b/packages/ai/src/providers/azure.ts
@@ -99,10 +99,14 @@ export const configure = (input: Config) => {
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
- configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
+ configuredResponsesRoute
+ .with(withOpenAIOptions(modelID, modelDefaults))
+ .model({ id: modelID })
const chat = (modelID: string | ModelID) =>
- configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
+ configuredChatRoute
+ .with(withOpenAIOptions(modelID, modelDefaults))
+ .model({ id: modelID })
return {
id,
@@ -133,8 +137,12 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
-export const responsesModel: ProviderPackage.Definition["model"] = (modelID, settings) =>
- configure(config(settings)).responses(modelID)
-export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) =>
- configure(config(settings)).chat(modelID)
+export const responsesModel: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => configure(config(settings)).responses(modelID)
+export const chatModel: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => configure(config(settings)).chat(modelID)
export const model = responsesModel
diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts
index a006152e98..a8e911feef 100644
--- a/packages/ai/src/providers/cloudflare.ts
+++ b/packages/ai/src/providers/cloudflare.ts
@@ -4,6 +4,7 @@ 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")
@@ -20,10 +21,11 @@ type GatewayURL = AtLeastOne<{
}
export type AIGatewayOptions = GatewayURL &
- RouteDefaultsInput &
+ Omit &
ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret
+ readonly providerOptions?: OpenAIProviderOptionsInput
}
type WorkersAIURL = AtLeastOne<{
@@ -31,7 +33,11 @@ type WorkersAIURL = AtLeastOne<{
readonly baseURL: string
}>
-export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
+export type WorkersAIOptions = WorkersAIURL &
+ Omit &
+ ProviderAuthOption<"optional"> & {
+ readonly providerOptions?: OpenAIProviderOptionsInput
+ }
export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
@@ -98,7 +104,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
})
return {
id: aiGatewayID,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureAIGateway,
}
}
@@ -111,7 +117,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
})
return {
id: workersAIID,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureWorkersAI,
}
}
diff --git a/packages/ai/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts
index cc776d3b15..ff75d0a776 100644
--- a/packages/ai/src/providers/github-copilot.ts
+++ b/packages/ai/src/providers/github-copilot.ts
@@ -50,9 +50,11 @@ 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({ id: modelID })
+ responsesRoute
+ .with(withOpenAIOptions(modelID, defaults(options)))
+ .model({ id: modelID })
const chat = (modelID: string | ModelID) =>
- chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
+ chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
return {
id,
model: (modelID: string | ModelID) =>
diff --git a/packages/ai/src/providers/google-vertex-chat.ts b/packages/ai/src/providers/google-vertex-chat.ts
index e2f9c1a415..eea7b2b8e5 100644
--- a/packages/ai/src/providers/google-vertex-chat.ts
+++ b/packages/ai/src/providers/google-vertex-chat.ts
@@ -1,8 +1,9 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client"
-import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
+import { ProviderID, type ModelID } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
+import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("google-vertex")
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
+ readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
- readonly providerOptions?: ProviderOptions
+ readonly providerOptions?: OpenAIProviderOptionsInput
}
const route = OpenAICompatibleChat.route.with({
@@ -56,7 +58,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -66,7 +68,7 @@ export const provider = {
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
diff --git a/packages/ai/src/providers/google-vertex-messages.ts b/packages/ai/src/providers/google-vertex-messages.ts
index 53c4e16b0d..9dbbc441a1 100644
--- a/packages/ai/src/providers/google-vertex-messages.ts
+++ b/packages/ai/src/providers/google-vertex-messages.ts
@@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -101,7 +101,10 @@ export const provider = {
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
diff --git a/packages/ai/src/providers/google-vertex-responses.ts b/packages/ai/src/providers/google-vertex-responses.ts
index e36c67472c..d03358f36c 100644
--- a/packages/ai/src/providers/google-vertex-responses.ts
+++ b/packages/ai/src/providers/google-vertex-responses.ts
@@ -1,8 +1,9 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
import type { RouteDefaultsInput } from "../route/client"
-import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
+import { ProviderID, type ModelID } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
+import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
export const id = ProviderID.make("google-vertex")
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
+ readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
- readonly providerOptions?: ProviderOptions
+ readonly providerOptions?: OpenResponsesProviderOptionsInput
}
const route = OpenAICompatibleResponses.route.with({
@@ -58,7 +60,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -68,7 +70,10 @@ export const provider = {
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts
index 78f4e0764d..78037a2828 100644
--- a/packages/ai/src/providers/google-vertex.ts
+++ b/packages/ai/src/providers/google-vertex.ts
@@ -77,7 +77,8 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
export const configure = (input: Config = {}) => {
return {
id,
- model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
+ model: (modelID: string | ModelID) =>
+ configuredRoute(input, modelID).model({ id: modelID }),
configure,
}
}
@@ -86,7 +87,10 @@ export const provider = {
id,
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["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({
diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts
index 2c3476117c..89da1a923a 100644
--- a/packages/ai/src/providers/google.ts
+++ b/packages/ai/src/providers/google.ts
@@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => {
})
return {
id,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
image,
configure,
}
}
export const provider = configure()
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) =>
+export const model: ProviderPackage.Definition["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
diff --git a/packages/ai/src/providers/openai-compatible-responses.ts b/packages/ai/src/providers/openai-compatible-responses.ts
index 38293eb1d8..fe2fc49bc5 100644
--- a/packages/ai/src/providers/openai-compatible-responses.ts
+++ b/packages/ai/src/providers/openai-compatible-responses.ts
@@ -36,7 +36,7 @@ export const configure = (input: Config) => {
})
return {
id: ProviderID.make(provider),
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
@@ -46,7 +46,10 @@ export const provider = {
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) =>
+export const model: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
diff --git a/packages/ai/src/providers/openai-compatible.ts b/packages/ai/src/providers/openai-compatible.ts
index 8b5d23eed7..e4e1be32fc 100644
--- a/packages/ai/src/providers/openai-compatible.ts
+++ b/packages/ai/src/providers/openai-compatible.ts
@@ -4,13 +4,15 @@ 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 = RouteDefaultsInput &
+type GenericModelOptions = Omit &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
+ readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -19,9 +21,10 @@ export interface Settings extends ProviderPackage.Settings {
readonly provider?: string
}
-export type FamilyModelOptions = RouteDefaultsInput &
+export type FamilyModelOptions = Omit &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
+ readonly providerOptions?: OpenAIProviderOptionsInput
}
export const routes = [OpenAICompatibleChat.route]
@@ -37,7 +40,8 @@ export const configure = (input: GenericModelOptions) => {
})
return {
id: ProviderID.make(provider),
- model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
+ model: (modelID: string | ModelID) =>
+ route.model({ id: modelID, provider: ProviderID.make(provider) }),
configure,
}
}
@@ -63,7 +67,7 @@ export const provider = {
configure,
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) =>
+export const model: ProviderPackage.Definition["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts
index 862333e2e5..c8213644b8 100644
--- a/packages/ai/src/providers/openai.ts
+++ b/packages/ai/src/providers/openai.ts
@@ -86,10 +86,15 @@ 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({ id })
+ responsesRoute
+ .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
+ .model({ id })
const responsesWebSocket = (id: string | ModelID) =>
- responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
- const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
+ 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,
@@ -132,15 +137,17 @@ const config = (settings: Settings): Config => {
}
}
-export const model: ProviderPackage.Definition["model"] = (modelID, settings) => {
+export const model: ProviderPackage.Definition["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["model"] = (modelID, settings) =>
- configure(config(settings)).chat(modelID)
+export const chatModel: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) => configure(config(settings)).chat(modelID)
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts
index 10b7fdd1e9..29f75b409e 100644
--- a/packages/ai/src/providers/openrouter.ts
+++ b/packages/ai/src/providers/openrouter.ts
@@ -5,6 +5,7 @@ import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
+import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"
@@ -30,6 +31,12 @@ export type ModelOptions = Omit &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
+export interface Settings extends ProviderPackage.Settings {
+ readonly apiKey?: string
+ readonly baseURL?: string
+ readonly providerOptions?: OpenRouterProviderOptionsInput
+}
+
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
@@ -107,10 +114,21 @@ export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
- model: (modelID: string | ModelID) => route.model({ id: modelID }),
+ model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = configure()
-export const model = provider.model
+export const model: ProviderPackage.Definition["model"] = (
+ modelID,
+ settings,
+) =>
+ configure({
+ apiKey: settings.apiKey,
+ baseURL: settings.baseURL,
+ headers: settings.headers,
+ http: settings.body === undefined ? undefined : { body: { ...settings.body } },
+ limits: settings.limits,
+ providerOptions: settings.providerOptions,
+ }).model(modelID)
diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts
index c5d3220ee7..281a106357 100644
--- a/packages/ai/src/providers/xai.ts
+++ b/packages/ai/src/providers/xai.ts
@@ -5,14 +5,23 @@ 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"
+import type { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("xai")
-export type ModelOptions = RouteDefaultsInput &
+export type ModelOptions = Omit &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
+ readonly providerOptions?: OpenAIProviderOptionsInput
}
+export interface Settings extends ProviderPackage.Settings {
+ readonly apiKey?: string
+ readonly baseURL?: string
+ readonly providerOptions?: OpenAIProviderOptionsInput
+}
+
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
@@ -42,8 +51,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({ id: modelID })
- const chat = (modelID: string | ModelID) => chatRoute.model({ 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,
@@ -63,7 +72,15 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
-export const model = provider.model
+export const model: ProviderPackage.Definition["model"] = (modelID, settings) =>
+ configure({
+ apiKey: settings.apiKey,
+ baseURL: settings.baseURL,
+ headers: settings.headers,
+ http: settings.body === undefined ? undefined : { body: { ...settings.body } },
+ limits: settings.limits,
+ providerOptions: settings.providerOptions,
+ }).model(modelID)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts
index 067292329b..b935699f90 100644
--- a/packages/ai/src/route/client.ts
+++ b/packages/ai/src/route/client.ts
@@ -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, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
+import type { LLMError, ProtocolID, ProviderOptions } from "../schema"
import {
GenerationOptions,
HttpOptions,
@@ -20,7 +20,6 @@ import {
ModelLimits,
LLMError as LLMErrorClass,
LLMEvent,
- PreparedRequest,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -46,7 +45,7 @@ export interface Route {
readonly defaults: RouteDefaults
readonly body: RouteBody
readonly with: (patch: RoutePatch) => Route
- readonly model: (input: RouteMappedModelInput) => Model
+ readonly model: (input: RouteMappedModelInput) => Model
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect
readonly streamPrepared: (
prepared: Prepared,
@@ -93,12 +92,12 @@ export interface RoutePatch extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
-const makeRouteModel = (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({
+ return Model.make({
...mapped,
provider,
route,
@@ -142,17 +141,6 @@ 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(...)`) — 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: (request: LLMRequest) => Effect.Effect, LLMError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -296,7 +284,8 @@ function makeFromTransport(
defaults: mergeRouteDefaults(route.defaults, defaults),
})
},
- model: (input) => makeRouteModel(route, input),
+ model: (input: RouteMappedModelInput) =>
+ makeRouteModel(route, input),
prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
@@ -370,9 +359,6 @@ export function make(
})
}
-// `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
@@ -390,17 +376,17 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
}
})
-const prepareWith = Effect.fn("LLMClient.prepare")(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 compiled = yield* compile(request)
-
- return new PreparedRequest({
+ return {
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) =>
@@ -422,9 +408,6 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
-export const prepare = (request: LLMRequest) =>
- prepareWith(request) as Effect.Effect, LLMError>
-
export function stream(request: LLMRequest): Stream.Stream {
return Stream.unwrap(
Effect.gen(function* () {
@@ -453,7 +436,7 @@ export const layer: Layer.Layer = Layer
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
})
- return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
+ return Service.of({ stream, generate: generateWith(stream) })
}),
)
@@ -462,7 +445,6 @@ export const Route = { make } as const
export const LLMClient = {
Service,
layer,
- prepare,
stream,
generate,
} as const
diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts
index 654a892859..e1b9251c02 100644
--- a/packages/ai/src/schema/events.ts
+++ b/packages/ai/src/schema/events.ts
@@ -1,6 +1,5 @@
import { Schema } from "effect"
-import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
-import { ModelSchema } from "./options"
+import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
@@ -314,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type
-export class PreparedRequest extends Schema.Class("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(...)` 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 = Omit & {
- readonly body: Body
-}
-
const responseText = (events: ReadonlyArray) =>
events
.filter(LLMEvent.is.textDelta)
diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts
index c80bce0fda..df7c6544aa 100644
--- a/packages/ai/src/schema/options.ts
+++ b/packages/ai/src/schema/options.ts
@@ -178,7 +178,8 @@ export namespace ModelCompatibility {
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
}
-export class Model {
+export class Model {
+ declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
@@ -193,8 +194,8 @@ export class Model {
this.compatibility = input.compatibility
}
- static make(input: Model.Input) {
- return new Model({
+ static make(input: Model.Input) {
+ return new Model({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -203,7 +204,7 @@ export class Model {
})
}
- static input(model: Model): Model.ConstructorInput {
+ static input(model: Model): Model.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -213,9 +214,9 @@ export class Model {
}
}
- static update(model: Model, patch: Partial) {
+ static update(model: Model, patch: Partial) {
if (Object.keys(patch).length === 0) return model
- return Model.make({
+ return Model.make({
...Model.input(model),
...patch,
})
@@ -241,6 +242,8 @@ export namespace Model {
export type ModelInput = Model.Input
+export type ModelProviderOptions = SelectedModel extends Model ? Options : never
+
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export class CacheHint extends Schema.Class("LLM.CacheHint")({
diff --git a/packages/ai/src/testing.ts b/packages/ai/src/testing.ts
index 69619b96e5..dbddb8d988 100644
--- a/packages/ai/src/testing.ts
+++ b/packages/ai/src/testing.ts
@@ -99,7 +99,6 @@ export const layer = (options: LayerOptions = {}) =>
)
}) as LLMClientShape["stream"]
const client = LLMClient.Service.of({
- prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
stream,
generate: (request) =>
stream(request).pipe(
diff --git a/packages/ai/test/adapter.test.ts b/packages/ai/test/adapter.test.ts
index b2b180a2b2..9409d04a17 100644
--- a/packages/ai/test/adapter.test.ts
+++ b/packages/ai/test/adapter.test.ts
@@ -2,6 +2,7 @@ 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"
@@ -139,8 +140,7 @@ describe("llm route", () => {
it.effect("selects routes by model route value", () =>
Effect.gen(function* () {
- const llm = yield* LLMClient.Service
- const prepared = yield* llm.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
)
@@ -173,7 +173,7 @@ describe("llm route", () => {
framing: fakeFraming,
})
- const prepared = yield* (yield* LLMClient.Service).prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
)
diff --git a/packages/ai/test/cache-policy.test.ts b/packages/ai/test/cache-policy.test.ts
index 8e2535b54b..a862b8b65d 100644
--- a/packages/ai/test/cache-policy.test.ts
+++ b/packages/ai/test/cache-policy.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src"
-import { Auth, LLMClient } from "../src/route"
+import { Auth } from "../src/route"
+import { compileRequest } from "../src/route/client"
import { AmazonBedrock } from "../src/providers"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as Gemini from "../src/protocols/gemini"
@@ -31,7 +32,7 @@ const geminiModel = Gemini.route
describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "You are concise.",
@@ -50,7 +51,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: [
@@ -87,7 +88,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: openaiModel,
system: "Sys",
@@ -106,7 +107,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: geminiModel,
system: "Sys",
@@ -123,7 +124,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: bedrockModel,
system: [
@@ -157,7 +158,7 @@ describe("applyCachePolicy", () => {
it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -176,7 +177,7 @@ describe("applyCachePolicy", () => {
it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -195,7 +196,7 @@ describe("applyCachePolicy", () => {
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: [
@@ -241,7 +242,7 @@ describe("applyCachePolicy", () => {
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied)
- const prepared = yield* LLMClient.prepare(request)
+ const prepared = yield* compileRequest(request)
const body = prepared.body as {
tools: Array<{ cache_control?: unknown }>
@@ -261,7 +262,7 @@ describe("applyCachePolicy", () => {
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -278,7 +279,7 @@ describe("applyCachePolicy", () => {
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
@@ -296,7 +297,7 @@ describe("applyCachePolicy", () => {
it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
diff --git a/packages/ai/test/prepare.test.ts b/packages/ai/test/compile.test.ts
similarity index 93%
rename from packages/ai/test/prepare.test.ts
rename to packages/ai/test/compile.test.ts
index 6923c5a678..b64b173b0e 100644
--- a/packages/ai/test/prepare.test.ts
+++ b/packages/ai/test/compile.test.ts
@@ -4,6 +4,7 @@ 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"
@@ -44,7 +45,7 @@ describe("request option precedence", () => {
})
})
- it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
+ it.effect("compiles 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/" },
@@ -59,7 +60,7 @@ describe("request option precedence", () => {
providerOptions: { openai: { reasoningEffort: "medium" } },
},
})
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
@@ -141,7 +142,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* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
@@ -164,10 +165,8 @@ describe("request option precedence", () => {
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
- const withoutMaxTokens = yield* LLMClient.prepare(
- LLM.request({ model, prompt: "Say hello.", cache: "none" }),
- )
- const withMaxTokens = yield* LLMClient.prepare(
+ const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
+ const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
)
diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts
index cb32528634..d036392278 100644
--- a/packages/ai/test/exports.test.ts
+++ b/packages/ai/test/exports.test.ts
@@ -53,9 +53,7 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction()
- expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction()
- expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
diff --git a/packages/ai/test/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts
new file mode 100644
index 0000000000..8171a7b2e9
--- /dev/null
+++ b/packages/ai/test/llm-option-types.types.ts
@@ -0,0 +1,47 @@
+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({ 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 = { example: { mode: "fast" } }
+void options
diff --git a/packages/ai/test/provider-options/anthropic-compatible.types.ts b/packages/ai/test/provider-options/anthropic-compatible.types.ts
new file mode 100644
index 0000000000..3f7de8fd14
--- /dev/null
+++ b/packages/ai/test/provider-options/anthropic-compatible.types.ts
@@ -0,0 +1,13 @@
+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 } },
+})
diff --git a/packages/ai/test/provider-options/anthropic.types.ts b/packages/ai/test/provider-options/anthropic.types.ts
new file mode 100644
index 0000000000..2d6c104094
--- /dev/null
+++ b/packages/ai/test/provider-options/anthropic.types.ts
@@ -0,0 +1,13 @@
+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" } } },
+})
diff --git a/packages/ai/test/provider-options/azure.types.ts b/packages/ai/test/provider-options/azure.types.ts
new file mode 100644
index 0000000000..8441f5528a
--- /dev/null
+++ b/packages/ai/test/provider-options/azure.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/cloudflare.types.ts b/packages/ai/test/provider-options/cloudflare.types.ts
new file mode 100644
index 0000000000..6e2af83bae
--- /dev/null
+++ b/packages/ai/test/provider-options/cloudflare.types.ts
@@ -0,0 +1,13 @@
+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 } },
+})
diff --git a/packages/ai/test/provider-options/github-copilot.types.ts b/packages/ai/test/provider-options/github-copilot.types.ts
new file mode 100644
index 0000000000..6f1ea24432
--- /dev/null
+++ b/packages/ai/test/provider-options/github-copilot.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/google-vertex-chat.types.ts b/packages/ai/test/provider-options/google-vertex-chat.types.ts
new file mode 100644
index 0000000000..d64f0af14b
--- /dev/null
+++ b/packages/ai/test/provider-options/google-vertex-chat.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/google-vertex-messages.types.ts b/packages/ai/test/provider-options/google-vertex-messages.types.ts
new file mode 100644
index 0000000000..040a4e03da
--- /dev/null
+++ b/packages/ai/test/provider-options/google-vertex-messages.types.ts
@@ -0,0 +1,13 @@
+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 } },
+})
diff --git a/packages/ai/test/provider-options/google-vertex-responses.types.ts b/packages/ai/test/provider-options/google-vertex-responses.types.ts
new file mode 100644
index 0000000000..3a64689102
--- /dev/null
+++ b/packages/ai/test/provider-options/google-vertex-responses.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/google-vertex.types.ts b/packages/ai/test/provider-options/google-vertex.types.ts
new file mode 100644
index 0000000000..6098092f89
--- /dev/null
+++ b/packages/ai/test/provider-options/google-vertex.types.ts
@@ -0,0 +1,17 @@
+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" } } },
+})
diff --git a/packages/ai/test/provider-options/google.types.ts b/packages/ai/test/provider-options/google.types.ts
new file mode 100644
index 0000000000..3ccfdfb1a5
--- /dev/null
+++ b/packages/ai/test/provider-options/google.types.ts
@@ -0,0 +1,17 @@
+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" } } },
+})
diff --git a/packages/ai/test/provider-options/openai-compatible-responses.types.ts b/packages/ai/test/provider-options/openai-compatible-responses.types.ts
new file mode 100644
index 0000000000..cb178b3b97
--- /dev/null
+++ b/packages/ai/test/provider-options/openai-compatible-responses.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/openai-compatible.types.ts b/packages/ai/test/provider-options/openai-compatible.types.ts
new file mode 100644
index 0000000000..94cfd2d24c
--- /dev/null
+++ b/packages/ai/test/provider-options/openai-compatible.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/openai.types.ts b/packages/ai/test/provider-options/openai.types.ts
new file mode 100644
index 0000000000..9546e1271b
--- /dev/null
+++ b/packages/ai/test/provider-options/openai.types.ts
@@ -0,0 +1,13 @@
+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 } },
+})
diff --git a/packages/ai/test/provider-options/openrouter.types.ts b/packages/ai/test/provider-options/openrouter.types.ts
new file mode 100644
index 0000000000..8982a50edb
--- /dev/null
+++ b/packages/ai/test/provider-options/openrouter.types.ts
@@ -0,0 +1,13 @@
+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" } },
+})
diff --git a/packages/ai/test/provider-options/xai.types.ts b/packages/ai/test/provider-options/xai.types.ts
new file mode 100644
index 0000000000..619f543083
--- /dev/null
+++ b/packages/ai/test/provider-options/xai.types.ts
@@ -0,0 +1,13 @@
+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 } },
+})
diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts
index 45ab7dfa84..0bb1b1441b 100644
--- a/packages/ai/test/provider-package.test.ts
+++ b/packages/ai/test/provider-package.test.ts
@@ -21,6 +21,8 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"),
+ import("@opencode-ai/ai/providers/openrouter"),
+ import("@opencode-ai/ai/providers/xai"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -29,6 +31,35 @@ describe("provider package entrypoints", () => {
expect(modules[12].model).toBe(modules[13].model)
})
+ test("maps OpenRouter and xAI package settings onto executable models", async () => {
+ const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
+ const XAI = await import("@opencode-ai/ai/providers/xai")
+ const settings = {
+ apiKey: "fixture",
+ baseURL: "https://provider.example.test/v1",
+ headers: { "x-application": "opencode" },
+ body: { service_tier: "priority" },
+ limits: { context: 200_000, output: 64_000 },
+ }
+ const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
+ ...settings,
+ providerOptions: { openrouter: { usage: true } },
+ })
+ const xai = XAI.model("grok-4", {
+ ...settings,
+ providerOptions: { openai: { reasoningEffort: "high" } },
+ })
+
+ for (const selected of [openrouter, xai]) {
+ expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
+ expect(selected.route.defaults.headers).toEqual(settings.headers)
+ expect(selected.route.defaults.http?.body).toEqual(settings.body)
+ expect(selected.route.defaults.limits).toEqual(settings.limits)
+ }
+ expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
+ expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
+ })
+
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts
index af1b3d99c8..920978258c 100644
--- a/packages/ai/test/provider/anthropic-messages.test.ts
+++ b/packages/ai/test/provider/anthropic-messages.test.ts
@@ -3,6 +3,7 @@ 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"
@@ -44,7 +45,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro
describe("Anthropic Messages route", () => {
it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(request)
+ const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "claude-sonnet-4-5",
@@ -59,7 +60,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
@@ -76,17 +77,17 @@ describe("Anthropic Messages route", () => {
it.effect("normalizes enabled and disabled thinking settings", () =>
Effect.gen(function* () {
- const enabled = yield* LLMClient.prepare(
+ const enabled = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
}),
)
- const legacy = yield* LLMClient.prepare(
+ const legacy = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
}),
)
- const disabled = yield* LLMClient.prepare(
+ const disabled = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}),
@@ -100,7 +101,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
}),
@@ -112,7 +113,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
@@ -137,7 +138,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -164,7 +165,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
@@ -181,7 +182,7 @@ describe("Anthropic Messages route", () => {
it.effect("falls back for unsupported native chronological system update placement", () =>
Effect.gen(function* () {
expect(
- (yield* LLMClient.prepare(
+ (yield* compileRequest(
LLM.request({
model: opus48,
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
@@ -196,12 +197,11 @@ describe("Anthropic Messages route", () => {
},
])
expect(
- (yield* LLMClient.prepare(
- LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
- )).body.messages,
+ (yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" })))
+ .body.messages,
).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }])
expect(
- (yield* LLMClient.prepare(
+ (yield* compileRequest(
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* LLMClient.prepare(
+ const error = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(
+ const error = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -628,9 +628,7 @@ describe("Anthropic Messages route", () => {
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
- const prepared = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message], cache: "none" }),
- )
+ 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" }] },
])
@@ -773,7 +771,7 @@ describe("Anthropic Messages route", () => {
),
),
)
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1133,7 +1131,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_round_trip",
model,
@@ -1184,7 +1182,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
id: "req_unknown_server_tool",
model,
@@ -1261,7 +1259,7 @@ describe("Anthropic Messages route", () => {
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
@@ -1277,7 +1275,7 @@ describe("Anthropic Messages route", () => {
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
@@ -1318,7 +1316,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
system: [
@@ -1344,7 +1342,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts
index 5cc8af1378..0bca4b9c5c 100644
--- a/packages/ai/test/provider/bedrock-converse.test.ts
+++ b/packages/ai/test/provider/bedrock-converse.test.ts
@@ -13,6 +13,7 @@ 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"
@@ -101,7 +102,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* LLMClient.prepare(baseRequest)
+ const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -114,7 +115,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
}),
@@ -129,14 +130,14 @@ describe("Bedrock Converse route", () => {
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(baseRequest)
+ const prepared = yield* compileRequest(baseRequest)
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -153,7 +154,7 @@ describe("Bedrock Converse route", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
@@ -187,7 +188,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
@@ -217,7 +218,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_history",
model,
@@ -256,7 +257,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_image",
model,
@@ -491,7 +492,7 @@ describe("Bedrock Converse route", () => {
providerMetadata: { bedrock: { signature: "sig_1" } },
})
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -546,9 +547,7 @@ describe("Bedrock Converse route", () => {
},
])
- const prepared = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message], cache: "none" }),
- )
+ const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
expect(prepared.body.messages).toEqual([
{
role: "assistant",
@@ -639,7 +638,7 @@ describe("Bedrock Converse route", () => {
text: "",
providerMetadata: { bedrock: { redactedData } },
})
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -754,7 +753,7 @@ describe("Bedrock Converse route", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
- const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
+ const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
@@ -764,7 +763,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_cache",
model,
@@ -796,7 +795,7 @@ describe("Bedrock Converse route", () => {
it.effect("does not emit cachePoint when no cache hint is set", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(baseRequest)
+ const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toMatchObject({
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
@@ -806,7 +805,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image media into Bedrock image blocks", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_image",
model,
@@ -843,7 +842,7 @@ describe("Bedrock Converse route", () => {
it.effect("base64-encodes Uint8Array image bytes", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_image_bytes",
model,
@@ -865,7 +864,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_doc",
model,
@@ -897,7 +896,7 @@ describe("Bedrock Converse route", () => {
it.effect("requires names for document media", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
@@ -910,7 +909,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
@@ -936,7 +935,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
@@ -988,7 +987,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
id: "req_bad_image",
model,
@@ -1002,7 +1001,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported document media types", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
id: "req_bad_doc",
model,
@@ -1017,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
system: [{ type: "text", text: "system", cache }],
@@ -1034,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
@@ -1066,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
system: [
diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts
index 309f5db0ff..e09e321873 100644
--- a/packages/ai/test/provider/cloudflare.test.ts
+++ b/packages/ai/test/provider/cloudflare.test.ts
@@ -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 { LLMClient } from "../../src/route"
+import { compileRequest } from "../../src/route/client"
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* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
+ const prepared = yield* compileRequest(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* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
+ const replay = yield* compileRequest(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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
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* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
+ const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-workers-ai")
expect(prepared.body).toMatchObject({
diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts
index 2b22401e0a..07325b9702 100644
--- a/packages/ai/test/provider/gemini.test.ts
+++ b/packages/ai/test/provider/gemini.test.ts
@@ -2,6 +2,7 @@ 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"
@@ -26,7 +27,7 @@ const request = LLM.request({
describe("Gemini route", () => {
it.effect("prepares Gemini target", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(request)
+ const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
@@ -38,12 +39,12 @@ describe("Gemini route", () => {
it.effect("normalizes Gemini thinking options", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
- const filtered = yield* LLMClient.prepare(
+ const filtered = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
@@ -59,7 +60,7 @@ describe("Gemini route", () => {
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -75,7 +76,7 @@ describe("Gemini route", () => {
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -143,7 +144,7 @@ describe("Gemini route", () => {
it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -188,7 +189,7 @@ describe("Gemini route", () => {
it.effect("strips matching data URLs to raw base64 inlineData", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -229,7 +230,7 @@ describe("Gemini route", () => {
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -238,7 +239,7 @@ describe("Gemini route", () => {
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -256,7 +257,7 @@ describe("Gemini route", () => {
it.effect("keeps tools and sends function calling mode NONE", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_choice_none",
model,
@@ -276,7 +277,7 @@ describe("Gemini route", () => {
it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_schema_patch",
model,
@@ -457,7 +458,7 @@ describe("Gemini route", () => {
response.events.findIndex((event) => event.type === "tool-call"),
)
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -691,7 +692,7 @@ describe("Gemini route", () => {
it.effect("rejects unsupported assistant media content", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
id: "req_media",
model,
diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts
index 395d983ffe..8c031d473c 100644
--- a/packages/ai/test/provider/google-vertex.test.ts
+++ b/packages/ai/test/provider/google-vertex.test.ts
@@ -4,6 +4,7 @@ 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"
@@ -182,7 +183,7 @@ describe("Google Vertex providers", () => {
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts
index a8b40346cf..4c881d20fb 100644
--- a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts
+++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts
@@ -5,6 +5,7 @@ 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"
@@ -84,9 +85,7 @@ for (const item of cases) {
),
).toBe(true)
- const replay = yield* LLMClient.prepare(
- LLM.request({ model: item.model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts
index 57ae8e8653..27d1f296dd 100644
--- a/packages/ai/test/provider/openai-chat.test.ts
+++ b/packages/ai/test/provider/openai-chat.test.ts
@@ -18,6 +18,7 @@ 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"
@@ -42,11 +43,7 @@ const request = LLM.request({
describe("OpenAI Chat route", () => {
it.effect("prepares OpenAI Chat payload", () =>
Effect.gen(function* () {
- // 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(request)
- const _typed: { readonly model: string; readonly stream: true } = prepared.body
+ const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
@@ -64,7 +61,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -87,7 +84,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -105,7 +102,7 @@ describe("OpenAI Chat route", () => {
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [
@@ -131,7 +128,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
@@ -144,7 +141,7 @@ describe("OpenAI Chat route", () => {
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
@@ -159,7 +156,7 @@ describe("OpenAI Chat route", () => {
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "think",
@@ -253,7 +250,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares assistant tool-call and tool-result messages", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -291,7 +288,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -311,7 +308,7 @@ describe("OpenAI Chat route", () => {
it.effect("continues image tool results as vision input without base64 text", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -355,7 +352,7 @@ describe("OpenAI Chat route", () => {
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -405,7 +402,7 @@ describe("OpenAI Chat route", () => {
it.effect("aggregates consecutive tool images with a following system update", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -446,7 +443,7 @@ describe("OpenAI Chat route", () => {
it.effect("appends system updates without replacing multipart user content", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -474,7 +471,7 @@ describe("OpenAI Chat route", () => {
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -483,7 +480,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -501,7 +498,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares raw and data URL image media as vision input", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_media",
model,
@@ -528,7 +525,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers reasoning-only assistant history", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning",
model,
@@ -619,9 +616,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: field },
})
- const replay = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
}
}),
@@ -647,9 +642,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "vendor_reasoning" },
})
- const replay = yield* LLMClient.prepare(
- LLM.request({ model: custom, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
}),
)
@@ -692,9 +685,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
- const replay = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
@@ -737,9 +728,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: details },
})
- const replay = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
@@ -764,9 +753,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
- const replay = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
@@ -839,9 +826,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: [] },
})
- const replay = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
@@ -889,9 +874,7 @@ describe("OpenAI Chat route", () => {
response.events.findIndex(LLMEvent.is.textStart),
)
- const replay = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
@@ -918,9 +901,7 @@ 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* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
@@ -950,7 +931,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* LLMClient.prepare(
+ const replay = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -979,7 +960,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* LLMClient.prepare(
+ const replay = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1004,7 +985,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* LLMClient.prepare(
+ const replay = yield* compileRequest(
LLM.request({
model,
messages: [
diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts
index 6565820af8..397c64c8b3 100644
--- a/packages/ai/test/provider/openai-compatible-chat.test.ts
+++ b/packages/ai/test/provider/openai-compatible-chat.test.ts
@@ -3,6 +3,7 @@ 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"
@@ -52,7 +53,7 @@ const providerFamilies = [
describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
toolChoice: ToolChoice.make({ type: "required" }),
@@ -127,7 +128,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(request)
+ const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "deepseek-chat",
@@ -145,7 +146,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_parity",
model,
diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts
index f6591f50d2..7d52b10cd1 100644
--- a/packages/ai/test/provider/openai-compatible-responses.test.ts
+++ b/packages/ai/test/provider/openai-compatible-responses.test.ts
@@ -7,6 +7,7 @@ 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"
@@ -23,7 +24,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
system: "You are concise.",
@@ -61,7 +62,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
).pipe(Effect.flip)
@@ -76,7 +77,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -102,7 +103,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model")
- const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
+ const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts
index a3d75a7eeb..3b9a0b76aa 100644
--- a/packages/ai/test/provider/openai-responses.test.ts
+++ b/packages/ai/test/provider/openai-responses.test.ts
@@ -14,6 +14,7 @@ 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"
@@ -56,7 +57,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(request)
+ const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
@@ -74,7 +75,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
@@ -92,7 +93,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
@@ -109,7 +110,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* LLMClient.prepare(input)
+ const prepared = yield* compileRequest(input)
expect(prepared.body).toMatchObject({ service_tier: "priority" })
expect(prepared.body).not.toHaveProperty("serviceTier")
@@ -118,7 +119,7 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
)
@@ -128,7 +129,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
)
@@ -138,7 +139,7 @@ describe("OpenAI Responses route", () => {
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({
@@ -191,7 +192,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -217,7 +218,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -395,7 +396,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares function call and function output input items", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -432,7 +433,7 @@ describe("OpenAI Responses route", () => {
content: [],
structured: {},
}
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -453,7 +454,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps primitive tool errors as plain text", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -469,7 +470,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps non-JSON tool errors as plain text", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -487,7 +488,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_image",
model,
@@ -516,7 +517,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers single-image tool-result content as structured input_image array", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_image_only",
model,
@@ -540,7 +541,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_pdf",
model,
@@ -575,7 +576,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
@@ -610,7 +611,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
@@ -633,7 +634,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
continuationRequest({
id: "req_native_continuation_openai",
model,
@@ -675,7 +676,7 @@ describe("OpenAI Responses route", () => {
it.effect("maps OpenAI provider options to Responses options", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
@@ -700,7 +701,7 @@ describe("OpenAI Responses route", () => {
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
@@ -722,7 +723,7 @@ describe("OpenAI Responses route", () => {
it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
@@ -739,7 +740,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
)
@@ -749,7 +750,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
@@ -759,7 +760,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits include when no include is set", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
)
@@ -773,7 +774,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
@@ -788,7 +789,7 @@ describe("OpenAI Responses route", () => {
it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
@@ -802,7 +803,7 @@ describe("OpenAI Responses route", () => {
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/",
@@ -934,9 +935,7 @@ describe("OpenAI Responses route", () => {
},
])
- const prepared = yield* LLMClient.prepare(
- LLM.request({ model, messages: [response.message] }),
- )
+ const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
role: "assistant",
@@ -1270,7 +1269,7 @@ describe("OpenAI Responses route", () => {
it.effect("preserves assistant content order around reasoning items", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_order",
model,
@@ -1308,7 +1307,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1330,7 +1329,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored provider-executed hosted tool results by id", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1367,7 +1366,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1408,7 +1407,7 @@ describe("OpenAI Responses route", () => {
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_multi_summary_continuation",
model,
@@ -1445,7 +1444,7 @@ describe("OpenAI Responses route", () => {
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_without_encrypted_state",
model,
@@ -1762,7 +1761,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
id: "req_media",
model,
@@ -1793,7 +1792,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
@@ -1825,7 +1824,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
+ const error = yield* compileRequest(
LLM.request({
id: "req_media",
model,
diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts
index b4ac2fe2e5..e2a61c250e 100644
--- a/packages/ai/test/provider/openrouter.test.ts
+++ b/packages/ai/test/provider/openrouter.test.ts
@@ -2,6 +2,7 @@ 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"
@@ -19,7 +20,7 @@ describe("OpenRouter", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
- const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
+ const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("openrouter")
expect(prepared.body).toMatchObject({
@@ -32,7 +33,7 @@ describe("OpenRouter", () => {
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
@@ -100,7 +101,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -133,7 +134,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -158,7 +159,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -179,7 +180,7 @@ describe("OpenRouter", () => {
it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts
index a9df815daf..4f83942a66 100644
--- a/packages/ai/test/tool-schema-projection.test.ts
+++ b/packages/ai/test/tool-schema-projection.test.ts
@@ -3,7 +3,8 @@ import { Effect } from "effect"
import { LLM } from "../src"
import { OpenAIChat } from "../src/protocols"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
-import { Auth, LLMClient } from "../src/route"
+import { Auth } from "../src/route"
+import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect"
describe("tool schema projections", () => {
@@ -79,7 +80,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* LLMClient.prepare(
+ const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx
index 016c678372..6fe0c44560 100644
--- a/packages/app/src/components/dialog-fork.tsx
+++ b/packages/app/src/components/dialog-fork.tsx
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
- .api.session.fork({ sessionID, messageID: item.id })
+ .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 3d500d7507..d562c1cb0b 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -131,8 +131,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
- .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts
index 983b4043be..fae7431bd7 100644
--- a/packages/app/src/context/global-sync/utils.ts
+++ b/packages/app/src/context/global-sync/utils.ts
@@ -155,6 +155,7 @@ export function sanitizeProject(project: Project) {
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
+ worktree: "canonical" in project ? project.canonical : project.worktree,
vcs: project.vcs === "git" ? "git" : undefined,
}
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 94a4b20aee..5f0ea9dddf 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -40,6 +40,9 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
+ "tree-sitter-bash": "0.25.0",
+ "tree-sitter-powershell": "0.25.10",
+ "web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts
index 9a67fd35d2..8bb9dda9f6 100644
--- a/packages/cli/script/build-node.ts
+++ b/packages/cli/script/build-node.ts
@@ -62,8 +62,8 @@ for (const target of targets) {
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
- await build(mainConfig(input))
await copyNodeAssets(assets)
+ await build(mainConfig(input))
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
diff --git a/packages/cli/script/node-assets.ts b/packages/cli/script/node-assets.ts
index 3f4823d823..adb871212e 100644
--- a/packages/cli/script/node-assets.ts
+++ b/packages/cli/script/node-assets.ts
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
-import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
+import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
+ ...Object.values(shellParserWasmAssets).map((key) => ({
+ key,
+ source: fileURLToPath(import.meta.resolve(key)),
+ })),
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts
index 963b5c3ed4..897d88c8d2 100644
--- a/packages/cli/src/acp/service.ts
+++ b/packages/cli/src/acp/service.ts
@@ -244,7 +244,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {}
},
forkSession: async (params) => {
- const forked = await input.client.session.fork({ sessionID: params.sessionId })
+ const forked = await input.client.session.fork({
+ sessionID: params.sessionId,
+ boundary: { type: "through" },
+ })
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
diff --git a/packages/cli/src/node/target.ts b/packages/cli/src/node/target.ts
index f39db8f820..4e62e4a02d 100644
--- a/packages/cli/src/node/target.ts
+++ b/packages/cli/src/node/target.ts
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
+export const shellParserWasmAssets = {
+ runtime: "web-tree-sitter/tree-sitter.wasm",
+ bash: "tree-sitter-bash/tree-sitter-bash.wasm",
+ powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
+} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
diff --git a/packages/cli/src/session-target.ts b/packages/cli/src/session-target.ts
index 975f4829b2..4bd670a85d 100644
--- a/packages/cli/src/session-target.ts
+++ b/packages/cli/src/session-target.ts
@@ -105,7 +105,7 @@ async function selectSession(input: {
return {
session: input.fork
? await input.client.session
- .fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
+ .fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
@@ -118,9 +118,11 @@ async function selectSession(input: {
if (!selected) return { session: undefined, location }
return {
session: input.fork
- ? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
- throw new SessionTargetMutationError(error)
- })
+ ? await input.client.session
+ .fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
+ .catch((error) => {
+ throw new SessionTargetMutationError(error)
+ })
: selected,
}
}
diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts
index 35a46a0ee2..2fc39b778c 100644
--- a/packages/cli/test/acp/service-lifecycle.test.ts
+++ b/packages/cli/test/acp/service-lifecycle.test.ts
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
- body: {},
+ body: { boundary: { type: "through" } },
})
})
diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts
index 8527bcaa57..c8a57d3637 100644
--- a/packages/cli/test/run/noninteractive.test.ts
+++ b/packages/cli/test/run/noninteractive.test.ts
@@ -267,7 +267,7 @@ async function run(input: {
values.push(...input.turn(messageID))
wake?.()
wake = undefined
- return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
+ return ok({ id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
})
await runNonInteractivePrompt({
client: sdk,
diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts
index ea34aada7f..0a5bf8c55d 100644
--- a/packages/cli/test/session-target.test.ts
+++ b/packages/cli/test/session-target.test.ts
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
function location(directory: string, workspaceID?: string): LocationGetOutput {
- return { directory, workspaceID, project: { id: "project", directory } }
+ return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts
index 7d7caf6d05..8b7bf9c826 100644
--- a/packages/cli/vite.node.config.ts
+++ b/packages/cli/vite.node.config.ts
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
-import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
+import {
+ nodeExecArgv,
+ nodeTarget,
+ type NodeTarget,
+ photonWasmAsset,
+ shellParserWasmAssets,
+} from "./src/node/target"
const dir = import.meta.dirname
@@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
+process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
+process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
+process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try {
diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts
index 3bb65cc007..80df5a87e4 100644
--- a/packages/client/src/effect/api/api.ts
+++ b/packages/client/src/effect/api/api.ts
@@ -136,7 +136,7 @@ export type Endpoint5_4Input = { readonly sessionID: Session.ID }
export type Endpoint5_4Output = void
export type SessionRemoveOperation = (input: Endpoint5_4Input) => Effect.Effect
-export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly messageID?: SessionMessage.ID | undefined }
+export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_5Output = Session.Info
export type SessionForkOperation = (input: Endpoint5_5Input) => Effect.Effect
@@ -342,8 +342,10 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly parentID: Session.ID
- readonly parentSeq: number
- readonly from?: SessionMessage.ID | undefined
+ readonly boundary: Session.ForkBoundary
+ readonly instructions?:
+ | { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
+ | undefined
}
}
| {
diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts
index 8b457db56f..7706c9aa9b 100644
--- a/packages/client/src/effect/generated/client.ts
+++ b/packages/client/src/effect/generated/client.ts
@@ -329,7 +329,7 @@ const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Inp
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
preserveEffect()(
- raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe(
+ raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts
index e6edc8a856..aa0563f948 100644
--- a/packages/client/src/promise/generated/client.ts
+++ b/packages/client/src/promise/generated/client.ts
@@ -510,7 +510,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
- body: { messageID: input["messageID"] },
+ body: { boundary: input["boundary"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts
index fcd25cc09b..8c8b6295a6 100644
--- a/packages/client/src/promise/generated/types.ts
+++ b/packages/client/src/promise/generated/types.ts
@@ -14,6 +14,8 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
+export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
+
export type MoneyUSD = number
export type TokenUsageInfo = {
@@ -43,13 +45,7 @@ export type PromptMention = { start: number; end: number; text: string }
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
-export type SessionPendingCompaction = {
- admittedSeq: number
- id: string
- sessionID: string
- timeCreated: number
- type: "compaction"
-}
+export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type SessionMessageAgentSelected = {
id: string
@@ -283,7 +279,7 @@ export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number; initialized?: number }
-export type ProjectCurrent = { id: string; directory: string }
+export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
@@ -628,7 +624,7 @@ export type SessionForked = {
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
- data: { sessionID: string; parentID: string; parentSeq: number; from?: string }
+ data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
}
export type SessionInputPromoted = {
@@ -1210,7 +1206,6 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type SessionPendingSynthetic = {
- admittedSeq: number
id: string
sessionID: string
timeCreated: number
@@ -1435,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array; templates: Arr
export type Project = {
id: string
- worktree: string
+ canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
@@ -1755,7 +1750,7 @@ export type PermissionRuleset = Array
export type SessionInfo = {
id: string
parentID?: string
- fork?: { sessionID: string; messageID?: string }
+ fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
@@ -1966,7 +1961,6 @@ export type AgentInfo = {
export type SessionsResponse = { data: Array; cursor: { previous?: string | null; next?: string | null } }
export type SessionPendingUser = {
- admittedSeq: number
id: string
sessionID: string
timeCreated: number
@@ -2521,7 +2515,11 @@ export type LocationGetInput = {
}["location"]
}
-export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+export type LocationGetOutput = {
+ directory: string
+ workspaceID?: string
+ project: { id: string; directory: string; canonical: string }
+}
export type AgentListInput = {
readonly location?: {
@@ -2530,7 +2528,7 @@ export type AgentListInput = {
}
export type AgentListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -2542,7 +2540,7 @@ export type AgentGetInput = {
}
export type AgentGetOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: AgentInfo
}
@@ -2553,7 +2551,7 @@ export type PluginListInput = {
}
export type PluginListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -2702,7 +2700,9 @@ export type SessionRemoveOutput = void
export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
- readonly messageID?: { readonly messageID?: string | undefined }["messageID"]
+ readonly boundary: {
+ readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" }
+ }["boundary"]
}
export type SessionForkOutput = { data: SessionInfo }["data"]
@@ -3235,7 +3235,7 @@ export type ModelListInput = {
}
export type ModelListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -3246,7 +3246,7 @@ export type ModelDefaultInput = {
}
export type ModelDefaultOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ModelInfo | null
}
@@ -3273,7 +3273,7 @@ export type ProviderListInput = {
}
export type ProviderListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -3285,7 +3285,7 @@ export type ProviderGetInput = {
}
export type ProviderGetOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ProviderInfo
}
@@ -3296,7 +3296,7 @@ export type IntegrationListInput = {
}
export type IntegrationListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -3308,7 +3308,7 @@ export type IntegrationGetInput = {
}
export type IntegrationGetOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationInfo | null
}
@@ -3355,7 +3355,7 @@ export type IntegrationOauthConnectInput = {
}
export type IntegrationOauthConnectOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: {
attemptID: string
url: string
@@ -3374,7 +3374,7 @@ export type IntegrationOauthStatusInput = {
}
export type IntegrationOauthStatusOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationAttemptStatus
}
@@ -3409,7 +3409,7 @@ export type IntegrationCommandConnectInput = {
}
export type IntegrationCommandConnectOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttempt
}
@@ -3422,7 +3422,7 @@ export type IntegrationCommandStatusInput = {
}
export type IntegrationCommandStatusOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttemptStatus
}
@@ -3443,7 +3443,7 @@ export type McpListInput = {
}
export type McpListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -3532,7 +3532,7 @@ export type McpResourceCatalogInput = {
}
export type McpResourceCatalogOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: McpResourceCatalog
}
@@ -3581,7 +3581,7 @@ export type FormRequestListInput = {
}
export type FormRequestListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4437,7 +4437,7 @@ export type PermissionRequestListInput = {
}
export type PermissionRequestListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4559,7 +4559,7 @@ export type FileListInput = {
}
export type FileListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4591,7 +4591,7 @@ export type FileFindInput = {
}
export type FileFindOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4602,7 +4602,7 @@ export type CommandListInput = {
}
export type CommandListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4613,7 +4613,7 @@ export type SkillListInput = {
}
export type SkillListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4626,7 +4626,7 @@ export type PtyListInput = {
}
export type PtyListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4672,7 +4672,7 @@ export type PtyCreateInput = {
}
export type PtyCreateOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4684,7 +4684,7 @@ export type PtyGetInput = {
}
export type PtyGetOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4701,7 +4701,7 @@ export type PtyUpdateInput = {
}
export type PtyUpdateOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4721,7 +4721,7 @@ export type ShellListInput = {
}
export type ShellListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4756,7 +4756,7 @@ export type ShellCreateInput = {
}
export type ShellCreateOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4768,7 +4768,7 @@ export type ShellGetInput = {
}
export type ShellGetOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4781,7 +4781,7 @@ export type ShellTimeoutInput = {
}
export type ShellTimeoutOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4805,7 +4805,7 @@ export type ShellOutputInput = {
}
export type ShellOutputOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { output: string; cursor: number; size: number; truncated: boolean }
}
@@ -4825,7 +4825,7 @@ export type QuestionRequestListInput = {
}
export type QuestionRequestListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4855,7 +4855,7 @@ export type ReferenceListInput = {
}
export type ReferenceListOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4898,7 +4898,7 @@ export type VcsStatusInput = {
}
export type VcsStatusOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4921,7 +4921,7 @@ export type VcsDiffInput = {
}
export type VcsDiffOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4942,7 +4942,7 @@ export type WebsearchProvidersInput = {
}
export type WebsearchProvidersOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array
}
@@ -4955,6 +4955,6 @@ export type WebsearchQueryInput = {
}
export type WebsearchQueryOutput = {
- location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
+ location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { providerID: string; results: Array }
}
diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts
index b52d86d382..eee0d2d08a 100644
--- a/packages/client/test/effect.test.ts
+++ b/packages/client/test/effect.test.ts
@@ -268,7 +268,6 @@ const session = {
const admission = {
data: {
- admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
type: "user",
@@ -281,7 +280,6 @@ const admission = {
const compactionAdmission = {
data: {
type: "compaction",
- admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts
index c3c0512f17..b38b141cbc 100644
--- a/packages/client/test/promise.test.ts
+++ b/packages/client/test/promise.test.ts
@@ -304,7 +304,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
- admittedSeq: 3,
id: "msg_pending",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
@@ -547,7 +546,6 @@ const session = {
const admission = {
data: {
- admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
type: "user",
@@ -559,7 +557,6 @@ const admission = {
const syntheticAdmission = {
data: {
- admittedSeq: 1,
id: "msg_synthetic",
sessionID: "ses_test",
type: "synthetic",
@@ -572,7 +569,6 @@ const syntheticAdmission = {
const compactionAdmission = {
data: {
type: "compaction",
- admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
diff --git a/packages/core/package.json b/packages/core/package.json
index 4d6f46c1c2..3b208f6f25 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -41,6 +41,11 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
+ "#shell-parser-wasm": {
+ "bun": "./src/shell/parser-wasm.bun.ts",
+ "node": "./src/shell/parser-wasm.node.ts",
+ "default": "./src/shell/parser-wasm.bun.ts"
+ },
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
@@ -119,7 +124,10 @@
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
+ "tree-sitter-bash": "0.25.0",
+ "tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
+ "web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
diff --git a/packages/core/schema.json b/packages/core/schema.json
index 2ef980ade3..7ed6583e21 100644
--- a/packages/core/schema.json
+++ b/packages/core/schema.json
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
- "id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798",
+ "id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"prevIds": [
- "5f0a1db8-d4bf-42c3-becb-96b46fe66bed"
+ "a4ba73b4-21bc-41ab-a415-94e2ca38d798"
],
"ddl": [
{
@@ -1266,17 +1266,7 @@
"autoincrement": false,
"default": null,
"generated": null,
- "name": "fork_message_id",
- "entityType": "columns",
- "table": "session"
- },
- {
- "type": "integer",
- "notNull": false,
- "autoincrement": false,
- "default": null,
- "generated": null,
- "name": "fork_seq",
+ "name": "fork_boundary",
"entityType": "columns",
"table": "session"
},
diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts
index eafc156894..00d1b71e2d 100644
--- a/packages/core/src/codemode/catalog.ts
+++ b/packages/core/src/codemode/catalog.ts
@@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
+ pinned: Schema.optionalKey(Schema.Boolean),
})
export type Entry = typeof Entry.Type
@@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET)
if (left.path > right.path) return 1
return 0
})
+ const ranked = rankListings(listings)
+ const pinned = new Set(
+ namespaceEntries
+ .filter((entry) => entry.pinned)
+ .map((entry) => listings.find((listing) => listing.path === entry.path))
+ .filter((listing) => listing !== undefined),
+ )
return {
name,
listings,
- selectionOrder: rankListings(listings),
- selectedListings: new Set(),
+ selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
+ selectedListings: pinned,
+ selectionIndex: 0,
}
})
const active = new Set(namespaces)
- let remaining = budget
+ let remaining =
+ budget -
+ namespaces
+ .flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
+ .reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
- const candidate = namespace.selectionOrder[namespace.selectedListings.size]
+ const candidate = namespace.selectionOrder[namespace.selectionIndex]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
+ namespace.selectionIndex += 1
remaining -= candidate.cost
- if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
+ if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
}
}
diff --git a/packages/core/src/codemode/instructions.ts b/packages/core/src/codemode/instructions.ts
index 7e5d135edb..50c432fb33 100644
--- a/packages/core/src/codemode/instructions.ts
+++ b/packages/core/src/codemode/instructions.ts
@@ -6,7 +6,9 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
-const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
+const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
+
+${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
## Search
diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts
index 7d9c5106be..b0a13ac58f 100644
--- a/packages/core/src/codemode/tool.ts
+++ b/packages/core/src/codemode/tool.ts
@@ -33,7 +33,7 @@ type CollectedFiles = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
- "Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
+ "Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.["tool-name"](input)`.',
@@ -138,7 +138,14 @@ export const create = (
}
export const catalog = (registrations: ReadonlyMap) => {
- return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
+ const pinned = new Set(
+ Array.from(registrations.values())
+ .filter((registration) => registration.options?.pinned === true)
+ .map(qualifiedName),
+ )
+ return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
+ .catalog()
+ .map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
}
function runtime(
@@ -149,11 +156,7 @@ function runtime(
const tools: Record> = {}
for (const [name, registration] of registrations) {
const child = definition(registration)
- const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
- const path =
- registration.options?.namespace === undefined
- ? normalized
- : `${registration.options.namespace}.${normalized}`
+ const path = qualifiedName(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
@@ -164,6 +167,12 @@ function runtime(
return CodeMode.make({ tools, ...hooks })
}
+function qualifiedName(registration: Info) {
+ const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+ if (registration.options?.namespace === undefined) return normalized
+ return `${registration.options.namespace}.${normalized}`
+}
+
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record | undefined {
if (input === null || input === undefined) return
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index 7ded4cc66c..05ba4b0db0 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -393,7 +393,8 @@ export const layer = (options?: Options) => Layer.effect(
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
- yield* watcher.subscribe(target).pipe(
+ const stream = yield* watcher.subscribe(target)
+ yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts
index a7eaed9b73..ee88d30f17 100644
--- a/packages/core/src/database/migration.gen.ts
+++ b/packages/core/src/database/migration.gen.ts
@@ -57,5 +57,6 @@ export const migrations = (
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
+ import("./migration/20260729022634_session_fork_boundary"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
diff --git a/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts
new file mode 100644
index 0000000000..bf574b4807
--- /dev/null
+++ b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts
@@ -0,0 +1,13 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+ id: "20260729022634_session_fork_boundary",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`)
+ yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`)
+ yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts
index b92b75e47b..05c63f435d 100644
--- a/packages/core/src/database/schema.gen.ts
+++ b/packages/core/src/database/schema.gen.ts
@@ -213,8 +213,7 @@ export default {
\`workspace_id\` text,
\`parent_id\` text,
\`fork_session_id\` text,
- \`fork_message_id\` text,
- \`fork_seq\` integer,
+ \`fork_boundary\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts
index 82312b22da..b5aee9a526 100644
--- a/packages/core/src/file-mutation.ts
+++ b/packages/core/src/file-mutation.ts
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Bom } from "@opencode-ai/util/bom"
export interface Target {
readonly canonical: string
@@ -108,13 +109,13 @@ const layer = Layer.effect(
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
- const next = splitBom(input.content)
+ const next = Bom.split(input.content)
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.canonical,
- joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
+ Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
@@ -172,20 +173,6 @@ const layer = Layer.effect(
}),
)
-function splitBom(text: string) {
- const stripped = text.replace(/^\uFEFF+/, "")
- return { bom: stripped.length !== text.length, text: stripped }
-}
-
-function joinBom(text: string, bom: boolean) {
- const stripped = splitBom(text).text
- return bom ? `\uFEFF${stripped}` : stripped
-}
-
-function hasUtf8Bom(content: Uint8Array) {
- return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
-}
-
function sameBytes(left: Uint8Array, right: Uint8Array) {
if (left.length !== right.length) return false
return left.every((byte, index) => byte === right[index])
diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts
index 25c9832059..934e99f3bf 100644
--- a/packages/core/src/filesystem/location-watcher.ts
+++ b/packages/core/src/filesystem/location-watcher.ts
@@ -47,13 +47,12 @@ const layer = Layer.effect(
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (!home && location.vcs) {
- yield* watcher
- .subscribe({
- path: location.directory,
- type: "directory",
- ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
- })
- .pipe(Stream.runForEach(publish), Effect.forkScoped)
+ const updates = yield* watcher.subscribe({
+ path: location.directory,
+ type: "directory",
+ ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
+ })
+ yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
@@ -68,9 +67,8 @@ const layer = Layer.effect(
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
- yield* watcher
- .subscribe({ path: vcs, type: "directory", ignore })
- .pipe(Stream.runForEach(publish), Effect.forkScoped)
+ const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
+ yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts
index f7fef53029..7ff3aa5e5c 100644
--- a/packages/core/src/filesystem/watcher.ts
+++ b/packages/core/src/filesystem/watcher.ts
@@ -59,7 +59,7 @@ export interface NativeInterface {
export class Native extends Context.Service()("@opencode/Watcher/Native") {}
export interface Interface {
- readonly subscribe: (input: WatchInput) => Stream.Stream
+ readonly subscribe: (input: WatchInput) => Effect.Effect>
}
export const Options = Schema.Struct({
@@ -83,7 +83,7 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
if (options?.enabled === false) {
- return Service.of({ subscribe: () => Stream.empty })
+ return Service.of({ subscribe: () => Effect.succeed(Stream.empty) })
}
const native = yield* Native
@@ -131,11 +131,19 @@ export const layer = (options?: Options) =>
const subscribe = (input: WatchInput) => {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
- return Stream.unwrap(
- RcMap.get(watchers, { type: input.type, target, ignore }).pipe(
- Effect.map((pubsub) => Stream.fromPubSub(pubsub)),
- ),
- )
+ return Effect.gen(function* () {
+ yield* Effect.logInfo("watcher subscribe", {
+ path: target,
+ type: input.type,
+ ignores: ignore.length,
+ })
+ return Stream.unwrap(
+ Effect.gen(function* () {
+ const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
+ return Stream.fromPubSub(pubsub)
+ }),
+ )
+ })
}
return Service.of({ subscribe })
diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts
new file mode 100644
index 0000000000..4eb6a91c5b
--- /dev/null
+++ b/packages/core/src/formatter.ts
@@ -0,0 +1,157 @@
+export * as Formatter from "./formatter"
+
+import { Context, Effect, Layer, Schema } from "effect"
+import { ChildProcess } from "effect/unstable/process"
+import path from "path"
+import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
+import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Npm } from "@opencode-ai/util/npm"
+import { AppProcess } from "@opencode-ai/util/process"
+import { Config } from "./config"
+import { Location } from "./location"
+import { make, type Info } from "./formatter/builtins"
+
+export const Status = Schema.Struct({
+ name: Schema.String,
+ extensions: Schema.Array(Schema.String),
+ enabled: Schema.Boolean,
+}).annotate({ identifier: "FormatterStatus" })
+export type Status = typeof Status.Type
+
+export interface Interface {
+ readonly init: () => Effect.Effect
+ readonly status: () => Effect.Effect
+ readonly file: (filepath: string) => Effect.Effect