Browse Source

refactor(ai): internalize request compilation (#39132)

Shoubhit Dash 2 weeks ago
parent
commit
d9555f138b

+ 3 - 3
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<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
+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

+ 0 - 1
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`.

+ 1 - 1
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.

+ 2 - 32
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))))

+ 6 - 25
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,
@@ -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<OpenAIChatBody>(...)`) — the runtime body is
-   * identical, so this is a type-level assertion the caller makes about which
-   * route the request will resolve to.
-   */
-  readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
   readonly stream: StreamMethod
   readonly generate: GenerateMethod
 }
@@ -370,9 +358,6 @@ export function make<Body, Prepared, Frame, Event, State>(
   })
 }
 
-// `compile` is the important boundary: it turns a common `LLMRequest` into a
-// validated provider body plus transport-private prepared data, but does not
-// execute transport.
 const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
   const resolved = applyCachePolicy(resolveRequestOptions(request))
   const route = resolved.model.route
@@ -390,17 +375,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 +407,6 @@ const generateWith = (stream: Interface["stream"]) =>
     )
   })
 
-export const prepare = <Body = unknown>(request: LLMRequest) =>
-  prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
-
 export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
   return Stream.unwrap(
     Effect.gen(function* () {
@@ -453,7 +435,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
       http: yield* RequestExecutor.Service,
       webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
     })
-    return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
+    return Service.of({ stream, generate: generateWith(stream) })
   }),
 )
 
@@ -462,7 +444,6 @@ export const Route = { make } as const
 export const LLMClient = {
   Service,
   layer,
-  prepare,
   stream,
   generate,
 } as const

+ 1 - 25
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<typeof llmEventTagged>
 
-export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
-  id: Schema.String,
-  route: RouteID,
-  protocol: ProtocolID,
-  model: ModelSchema,
-  body: Schema.Unknown,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
-}) {}
-
-/**
- * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
- * on `LLMClient.prepare<Body>(...)` when the caller knows which route their
- * request will resolve to and wants its native shape statically exposed
- * (debug UIs, request previews, plan rendering).
- *
- * The runtime body is identical — the route still emits `body: unknown` — so
- * this is a type-level assertion the caller makes about what they expect to
- * find. The prepare runtime does not validate the assertion.
- */
-export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
-  readonly body: Body
-}
-
 const responseText = (events: ReadonlyArray<LLMEvent>) =>
   events
     .filter(LLMEvent.is.textDelta)

+ 0 - 1
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(

+ 3 - 3
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 }) }),
       )
 

+ 14 - 13
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")],

+ 6 - 7
packages/ai/test/prepare.test.ts → 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<OpenAIChat.OpenAIChatBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
-        LLM.request({ model, prompt: "Say hello.", cache: "none" }),
-      )
-      const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+      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 } }),
       )
 

+ 31 - 33
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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      const enabled = yield* compileRequest(
         LLMRequest.update(request, {
           providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
         }),
       )
-      const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+      const legacy = yield* compileRequest(
         LLMRequest.update(request, {
           providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
         }),
       )
-      const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+        (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<AnthropicMessages.AnthropicMessagesBody>(
-          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: "<system-update>\nFirst.\n</system-update>" }] }])
       expect(
-        (yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+        (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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
+      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<AnthropicMessages.AnthropicMessagesBody>(
-        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<AnthropicMessages.AnthropicMessagesBody>(
+      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: [

+ 26 - 27
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<BedrockConverse.BedrockConverseBody>(
+      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<BedrockConverse.BedrockConverseBody>(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<BedrockConverse.BedrockConverseBody>(
+      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<BedrockConverse.BedrockConverseBody>(
+      const prepared = yield* compileRequest(
         LLM.request({
           model,
           messages: [
@@ -546,9 +547,7 @@ describe("Bedrock Converse route", () => {
         },
       ])
 
-      const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
-        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<BedrockConverse.BedrockConverseBody>(
+      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<BedrockConverse.BedrockConverseBody>(
+      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<BedrockConverse.BedrockConverseBody>(
+      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: [

+ 5 - 5
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({

+ 14 - 13
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<Gemini.GeminiBody>(
+      const prepared = yield* compileRequest(
         LLMRequest.update(request, {
           providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
         }),
       )
-      const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
+      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<Gemini.GeminiBody>(
+      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<Gemini.GeminiBody>(
+      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<Gemini.GeminiBody>(
+      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<Gemini.GeminiBody>(
+      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,

+ 2 - 1
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",

+ 2 - 3
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<OpenAIChat.OpenAIChatBody>(
-            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 },
           ])

+ 29 - 48
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<OpenAIChat.OpenAIChatBody>(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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
-          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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
-        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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      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<OpenAIChat.OpenAIChatBody>(
+      const replay = yield* compileRequest(
         LLM.request({
           model,
           messages: [

+ 4 - 3
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,

+ 5 - 4
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" },

+ 39 - 40
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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
-        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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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<OpenAIResponses.OpenAIResponsesBody>(
+      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,

+ 7 - 6
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<OpenRouter.OpenRouterBody>(
+      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<OpenRouter.OpenRouterBody>(
+      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<OpenRouter.OpenRouterBody>(
+      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<OpenRouter.OpenRouterBody>(
+      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" })],

+ 3 - 2
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<OpenAIChat.OpenAIChatBody>(
+      const prepared = yield* compileRequest(
         LLM.request({
           model,
           prompt: "Use the tool.",

+ 11 - 26
packages/core/test/aisdk.test.ts

@@ -1,9 +1,10 @@
-import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
+import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
 import { AISDK } from "@opencode-ai/core/aisdk"
 import { Model } from "@opencode-ai/core/model"
 import { Provider } from "@opencode-ai/core/provider"
 import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
 import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
+import { compileRequest } from "@opencode-ai/ai/route/client"
 import { expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { testEffect } from "./lib/effect"
@@ -87,9 +88,7 @@ it.effect("projects request settings, headers, and body overlays", () =>
       headers: { "x-test": "header" },
       body: { safety_setting: "strict" },
     })
-    const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: resolved, prompt: "Hello" }),
-    )
+    const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
 
     expect(prepared.body.providerOptions).toEqual({
       google: { thinkingConfig: { thinkingBudget: 1024 } },
@@ -112,9 +111,7 @@ it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
       ...model("@ai-sdk/openai"),
       body: { reasoning: { mode: "pro" } },
     })
-    const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: resolved, prompt: "Hello" }),
-    )
+    const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
 
     expect(body).toBeUndefined()
     expect(prepared.body.providerOptions).toEqual({
@@ -139,9 +136,7 @@ it.effect("maps package-specific AI SDK provider option keys", () =>
     ] as const
     for (const [packageName, key, settings] of cases) {
       const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
-      const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-        LLM.request({ model: resolved, prompt: "Hello" }),
-      )
+      const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
       expect(prepared.body.providerOptions).toEqual({ [key]: settings })
     }
   }),
@@ -155,17 +150,13 @@ it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
     })
 
     const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
-    const openaiPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: openai, prompt: "Hello" }),
-    )
+    const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" }))
     expect(openaiPrepared.body.providerOptions).toEqual({
       openai: { reasoningEffort: "high", forceReasoning: true },
     })
 
     const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
-    const azurePrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: azure, prompt: "Hello" }),
-    )
+    const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" }))
     expect(azurePrepared.body.providerOptions).toEqual({
       openai: { reasoningEffort: "high", forceReasoning: true },
       azure: { reasoningEffort: "high", forceReasoning: true },
@@ -187,9 +178,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
       }),
       modelID: Model.ID.make("anthropic/claude-sonnet-5"),
     })
-    const anthropicPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: anthropic, prompt: "Hello" }),
-    )
+    const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" }))
     expect(anthropicPrepared.body.providerOptions).toEqual({
       gateway: { order: ["anthropic"] },
       anthropic: { thinking: { type: "adaptive" } },
@@ -199,9 +188,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
       ...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
       modelID: Model.ID.make("amazon/nova-2-lite"),
     })
-    const bedrockPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: bedrock, prompt: "Hello" }),
-    )
+    const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" }))
     expect(bedrockPrepared.body.providerOptions).toEqual({
       bedrock: { reasoningConfig: { type: "enabled" } },
     })
@@ -210,9 +197,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
       ...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
       modelID: Model.ID.make("deepseek/deepseek-v4"),
     })
-    const fallbackPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
-      LLM.request({ model: fallback, prompt: "Hello" }),
-    )
+    const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" }))
     expect(fallbackPrepared.body.providerOptions).toEqual({
       deepseek: { reasoningEffort: "high" },
     })
@@ -228,7 +213,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
 
     const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
     expect(resolved.route.providerMetadataKey).toBe("anthropic")
-    const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
+    const prepared = yield* compileRequest(
       LLM.request({
         model: resolved,
         messages: [

+ 2 - 2
packages/core/test/model-resolver.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { LLM, Model } from "@opencode-ai/ai"
-import { LLMClient } from "@opencode-ai/ai/route"
+import { compileRequest } from "@opencode-ai/ai/route/client"
 import { Effect } from "effect"
 import { Headers } from "effect/unstable/http"
 import { Credential } from "@opencode-ai/core/credential"
@@ -69,7 +69,7 @@ describe("ModelResolver", () => {
           settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
         }),
       )
-      const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
+      const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
 
       expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
       expect(JSON.stringify(prepared.body)).not.toContain("secret")

+ 0 - 1
packages/core/test/session-compact.test.ts

@@ -41,7 +41,6 @@ const projects = Layer.succeed(
 )
 let requests: LLMRequest[] = []
 const client = Layer.mock(LLMClient.Service)({
-  prepare: () => Effect.die("unused"),
   stream: (request: LLMRequest) => {
     requests.push(request)
     return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))

+ 0 - 1
packages/core/test/session-compaction.test.ts

@@ -42,7 +42,6 @@ const cost = [
   },
 ]
 const client = Layer.mock(LLMClient.Service)({
-  prepare: () => Effect.die("unused"),
   stream: (request: LLMRequest) => {
     requests.push(request)
     return Stream.make(

+ 0 - 1
packages/core/test/session-generate.test.ts

@@ -49,7 +49,6 @@ const sessionID = SessionSchema.ID.make("ses_generate_test")
 
 const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
 const client = Layer.mock(LLMClient.Service)({
-  prepare: () => Effect.die(new Error("unused")),
   stream: () => Stream.die(new Error("unused")),
   generate: (request) =>
     Effect.sync(() => {

+ 0 - 1
packages/core/test/session-title.test.ts

@@ -40,7 +40,6 @@ const cost = [
   },
 ]
 const client = Layer.mock(LLMClient.Service)({
-  prepare: () => Effect.die("unused"),
   stream: (request: LLMRequest) => {
     requests.push(request)
     return Stream.make(