diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 6c35cafc34..ad4a625385 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -46,7 +46,7 @@ const response = yield * LLMClient.generate(request) `LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. -Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare(...)` returns a `PreparedRequestOf`). The runtime body is identical; the generic is a type-level assertion. +Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code. @@ -138,13 +138,13 @@ packages/ai/src/ ids.ts branded IDs, literal types, ProviderMetadata options.ts Generation/Provider/Http options, Limits, Model, cache policy messages.ts content parts, Message, ToolDefinition, LLMRequest - events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse + events.ts Usage, individual events, LLMEvent, LLMResponse errors.ts error reasons, LLMError, ToolFailure index.ts barrel llm.ts request constructors and convenience helpers route/ index.ts @opencode-ai/ai/route advanced barrel - client.ts Route.make + LLMClient.prepare/stream/generate + client.ts Route.make + LLMClient.stream/generate executor.ts RequestExecutor service + transport error mapping protocol.ts Protocol type + Protocol.make endpoint.ts Endpoint type + Endpoint.path diff --git a/packages/ai/README.md b/packages/ai/README.md index 377e09306f..6d89fa317b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -196,7 +196,6 @@ The hosted result is represented as a provider-executed tool call and tool resul - **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use. - **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. - **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. -- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing. - **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. - **`Image.generate({...})`** — generate images through a provider-neutral image request and response model. - **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. diff --git a/packages/ai/example/call-sites.md b/packages/ai/example/call-sites.md index 0b33d28c48..397e0cc09f 100644 --- a/packages/ai/example/call-sites.md +++ b/packages/ai/example/call-sites.md @@ -568,7 +568,7 @@ App boundary = explicit durable-config -> typed-provider call calling `.model(...)`. - [x] Remove request-shaping defaults from `Model`; selected models now carry only id, provider, and configured route while defaults live on routes or requests. -- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read +- [x] Rework `LLMClient.stream` / `generate` to read `request.model.route` directly instead of calling `registeredRoute(...)`. - [x] Remove `Route.make(...)` global registration from the normal execution path; keep route ids only as diagnostics/provider API labels. diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts index 7ee4abb146..3924a57dd2 100644 --- a/packages/ai/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -50,18 +50,6 @@ const request = LLM.request({ }, }) -// `http` is intentionally not needed for normal calls. This shows the shape for -// newly released provider fields before they deserve a typed provider option. -const rawOverlayExample = LLM.request({ - model, - prompt: "Show the final HTTP overlay shape.", - http: { - body: { metadata: { example: "tutorial" } }, - headers: { "x-opencode-tutorial": "1" }, - query: { debug: "1" }, - }, -}) - // 3. `generate` sends the request and collects the event stream into one // response object. `response.text` is the collected text output. const generateOnce = Effect.gen(function* () { @@ -222,33 +210,15 @@ const FakeEcho = { }), } -// `LLMClient.prepare` is the lower-level inspection hook: it compiles through -// body conversion, validation, endpoint, auth, and HTTP construction without -// sending anything over the network. -const inspectFakeProvider = Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( - LLM.request({ - model: FakeEcho.configure().model("tiny-echo"), - prompt: "Show me the provider pipeline.", - }), - ) - - console.log("\n== fake provider prepare ==") - console.log("route:", prepared.route) - console.log("body:", Formatter.formatJson(prepared.body, { space: 2 })) -}) - // Provide the LLM runtime and the HTTP request executor once. Keep one path -// enabled at a time so the tutorial can demonstrate generate, prepare, stream, -// or tool-loop behavior without spending tokens on every example. +// enabled at a time so the tutorial can demonstrate generate, stream, or +// tool-loop behavior without spending tokens on every example. const requestExecutorLayer = RequestExecutor.fetchLayer const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) const program = Effect.gen(function* () { // yield* generateOnce - // yield* inspectFakeProvider - // yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body)))) // yield* streamText // yield* generateStructuredObject // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 067292329b..048f82f554 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -10,7 +10,7 @@ import { WebSocketExecutor } from "./transport" import type { Protocol } from "./protocol" import { applyCachePolicy } from "../cache-policy" import * as ProviderShared from "../protocols/shared" -import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema" +import type { LLMError, ProtocolID, ProviderOptions } from "../schema" import { GenerationOptions, HttpOptions, @@ -20,7 +20,6 @@ import { ModelLimits, LLMError as LLMErrorClass, LLMEvent, - PreparedRequest, ProviderID, mergeGenerationOptions, mergeHttpOptions, @@ -142,17 +141,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => { } export interface Interface { - /** - * Compile a request through protocol body construction, validation, and HTTP - * preparation without sending it. Returns the prepared request including the - * provider-native body. - * - * Pass a `Body` type argument to statically expose the route's body - * shape (e.g. `prepare(...)`) — the runtime body is - * identical, so this is a type-level assertion the caller makes about which - * route the request will resolve to. - */ - readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> readonly stream: StreamMethod readonly generate: GenerateMethod } @@ -370,9 +358,6 @@ export function make( }) } -// `compile` is the important boundary: it turns a common `LLMRequest` into a -// validated provider body plus transport-private prepared data, but does not -// execute transport. const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { const resolved = applyCachePolicy(resolveRequestOptions(request)) const route = resolved.model.route @@ -390,17 +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 = (request: LLMRequest) => - prepareWith(request) as Effect.Effect, LLMError> - export function stream(request: LLMRequest): Stream.Stream { return Stream.unwrap( Effect.gen(function* () { @@ -453,7 +435,7 @@ export const layer: Layer.Layer = Layer http: yield* RequestExecutor.Service, webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)), }) - return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + return Service.of({ stream, generate: generateWith(stream) }) }), ) @@ -462,7 +444,6 @@ export const Route = { make } as const export const LLMClient = { Service, layer, - prepare, stream, generate, } as const diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 654a892859..e1b9251c02 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" -import { ModelSchema } from "./options" +import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids" import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ProviderFailureClassification } from "./errors" @@ -314,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, { }) export type LLMEvent = Schema.Schema.Type -export class PreparedRequest extends Schema.Class("LLM.PreparedRequest")({ - id: Schema.String, - route: RouteID, - protocol: ProtocolID, - model: ModelSchema, - body: Schema.Unknown, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} - -/** - * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic - * on `LLMClient.prepare(...)` when the caller knows which route their - * request will resolve to and wants its native shape statically exposed - * (debug UIs, request previews, plan rendering). - * - * The runtime body is identical — the route still emits `body: unknown` — so - * this is a type-level assertion the caller makes about what they expect to - * find. The prepare runtime does not validate the assertion. - */ -export type PreparedRequestOf = Omit & { - readonly body: Body -} - const responseText = (events: ReadonlyArray) => events .filter(LLMEvent.is.textDelta) diff --git a/packages/ai/src/testing.ts b/packages/ai/src/testing.ts index 69619b96e5..dbddb8d988 100644 --- a/packages/ai/src/testing.ts +++ b/packages/ai/src/testing.ts @@ -99,7 +99,6 @@ export const layer = (options: LayerOptions = {}) => ) }) as LLMClientShape["stream"] const client = LLMClient.Service.of({ - prepare: () => Effect.die("TestLLM does not prepare provider-native requests"), stream, generate: (request) => stream(request).pipe( diff --git a/packages/ai/test/adapter.test.ts b/packages/ai/test/adapter.test.ts index b2b180a2b2..9409d04a17 100644 --- a/packages/ai/test/adapter.test.ts +++ b/packages/ai/test/adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { LLM, LLMRequest, LLMResponse } from "../src" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" +import { compileRequest } from "../src/route/client" import { Model } from "../src/schema" import { testEffect } from "./lib/effect" import { dynamicResponse } from "./lib/http" @@ -139,8 +140,7 @@ describe("llm route", () => { it.effect("selects routes by model route value", () => Effect.gen(function* () { - const llm = yield* LLMClient.Service - const prepared = yield* llm.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }), ) @@ -173,7 +173,7 @@ describe("llm route", () => { framing: fakeFraming, }) - const prepared = yield* (yield* LLMClient.Service).prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }), ) diff --git a/packages/ai/test/cache-policy.test.ts b/packages/ai/test/cache-policy.test.ts index 8e2535b54b..a862b8b65d 100644 --- a/packages/ai/test/cache-policy.test.ts +++ b/packages/ai/test/cache-policy.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { CacheHint, LLM, Message } from "../src" -import { Auth, LLMClient } from "../src/route" +import { Auth } from "../src/route" +import { compileRequest } from "../src/route/client" import { AmazonBedrock } from "../src/providers" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as Gemini from "../src/protocols/gemini" @@ -31,7 +32,7 @@ const geminiModel = Gemini.route describe("applyCachePolicy", () => { it.effect("undefined cache resolves to 'auto' (the recommended default)", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "You are concise.", @@ -50,7 +51,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: [ @@ -87,7 +88,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: openaiModel, system: "Sys", @@ -106,7 +107,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: geminiModel, system: "Sys", @@ -123,7 +124,7 @@ describe("applyCachePolicy", () => { it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: bedrockModel, system: [ @@ -157,7 +158,7 @@ describe("applyCachePolicy", () => { it.effect("'none' disables auto placement even when manual hints exist", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "Sys", @@ -176,7 +177,7 @@ describe("applyCachePolicy", () => { it.effect("granular object form: tools-only marks just tools", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "Sys", @@ -195,7 +196,7 @@ describe("applyCachePolicy", () => { it.effect("auto policy preserves manual CacheHints on other parts", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: [ @@ -241,7 +242,7 @@ describe("applyCachePolicy", () => { expect("cache" in tail ? tail.cache : undefined).toBeUndefined() expect(applyCachePolicy(applied)).toBe(applied) - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) const body = prepared.body as { tools: Array<{ cache_control?: unknown }> @@ -261,7 +262,7 @@ describe("applyCachePolicy", () => { it.effect("ttlSeconds in the policy flows through to wire markers", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, system: "Sys", @@ -278,7 +279,7 @@ describe("applyCachePolicy", () => { it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], @@ -296,7 +297,7 @@ describe("applyCachePolicy", () => { it.effect("'latest-assistant' marks the last assistant message", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: anthropicModel, messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], diff --git a/packages/ai/test/prepare.test.ts b/packages/ai/test/compile.test.ts similarity index 93% rename from packages/ai/test/prepare.test.ts rename to packages/ai/test/compile.test.ts index 6923c5a678..b64b173b0e 100644 --- a/packages/ai/test/prepare.test.ts +++ b/packages/ai/test/compile.test.ts @@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http" import { LLM, mergeProviderOptions } from "../src" import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { Auth, LLMClient } from "../src/route" +import { compileRequest } from "../src/route/client" import { it } from "./lib/effect" import { dynamicResponse } from "./lib/http" import { deltaChunk } from "./lib/openai-chunks" @@ -44,7 +45,7 @@ describe("request option precedence", () => { }) }) - it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => + it.effect("compiles bodies with route defaults, model defaults, and call options in order", () => Effect.gen(function* () { const route = OpenAIChat.route.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, @@ -59,7 +60,7 @@ describe("request option precedence", () => { providerOptions: { openai: { reasoningEffort: "medium" } }, }, }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", @@ -141,7 +142,7 @@ describe("request option precedence", () => { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }) - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", @@ -164,10 +165,8 @@ describe("request option precedence", () => { limits: { output: 128 }, }) const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) - const withoutMaxTokens = yield* LLMClient.prepare( - LLM.request({ model, prompt: "Say hello.", cache: "none" }), - ) - const withMaxTokens = yield* LLMClient.prepare( + const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) + const withMaxTokens = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), ) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index af1b3d99c8..920978258c 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" import { it } from "../lib/effect" @@ -44,7 +45,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro describe("Anthropic Messages route", () => { it.effect("prepares Anthropic Messages target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "claude-sonnet-4-5", @@ -59,7 +60,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers adaptive thinking settings with effort", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, @@ -76,17 +77,17 @@ describe("Anthropic Messages route", () => { it.effect("normalizes enabled and disabled thinking settings", () => Effect.gen(function* () { - const enabled = yield* LLMClient.prepare( + const enabled = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } }, }), ) - const legacy = yield* LLMClient.prepare( + const legacy = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } }, }), ) - const disabled = yield* LLMClient.prepare( + const disabled = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "disabled" } } }, }), @@ -100,7 +101,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects enabled thinking without a budget", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLMRequest.update(request, { providerOptions: { anthropic: { thinking: { type: "enabled" } } }, }), @@ -112,7 +113,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -137,7 +138,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -164,7 +165,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects non-text chronological system update content before send", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -181,7 +182,7 @@ describe("Anthropic Messages route", () => { it.effect("falls back for unsupported native chronological system update placement", () => Effect.gen(function* () { expect( - (yield* LLMClient.prepare( + (yield* compileRequest( LLM.request({ model: opus48, messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], @@ -196,12 +197,11 @@ describe("Anthropic Messages route", () => { }, ]) expect( - (yield* LLMClient.prepare( - LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }), - )).body.messages, + (yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }))) + .body.messages, ).toEqual([{ role: "user", content: [{ type: "text", text: "\nFirst.\n" }] }]) expect( - (yield* LLMClient.prepare( + (yield* compileRequest( LLM.request({ model: opus48, messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")], @@ -223,7 +223,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects a system update between a local tool call and its result", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: opus48, messages: [ @@ -242,7 +242,7 @@ describe("Anthropic Messages route", () => { it.effect("prepares tool call and tool result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -273,7 +273,7 @@ describe("Anthropic Messages route", () => { it.effect("keeps tools and sends tool_choice none", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_choice_none", model, @@ -303,7 +303,7 @@ describe("Anthropic Messages route", () => { // not JSON-stringified into `tool_result.content`. it.effect("lowers media tool-result content as structured blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image", model, @@ -335,7 +335,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers single-image tool-result content as a structured image block", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image_only", model, @@ -360,7 +360,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects unsupported media in tool-result content with a clear error", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_tool_result_unsupported_media", model, @@ -384,7 +384,7 @@ describe("Anthropic Messages route", () => { it.effect("prepares the composed native continuation request", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( continuationRequest({ id: "req_native_continuation_anthropic", model, @@ -428,7 +428,7 @@ describe("Anthropic Messages route", () => { it.effect("lowers preserved Anthropic reasoning signature metadata", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -447,7 +447,7 @@ describe("Anthropic Messages route", () => { it.effect("round-trips redacted thinking as redacted_thinking blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -628,9 +628,7 @@ describe("Anthropic Messages route", () => { { type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } }, ]) - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message], cache: "none" }), - ) + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) expect(prepared.body.messages).toEqual([ { role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] }, ]) @@ -773,7 +771,7 @@ describe("Anthropic Messages route", () => { ), ), ) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1133,7 +1131,7 @@ describe("Anthropic Messages route", () => { it.effect("round-trips provider-executed assistant content into server tool blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_round_trip", model, @@ -1184,7 +1182,7 @@ describe("Anthropic Messages route", () => { it.effect("rejects round-trip for unknown server tool names", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_unknown_server_tool", model, @@ -1261,7 +1259,7 @@ describe("Anthropic Messages route", () => { it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, @@ -1277,7 +1275,7 @@ describe("Anthropic Messages route", () => { it.effect("emits cache_control on tool definitions and tool-result blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, tools: [ @@ -1318,7 +1316,7 @@ describe("Anthropic Messages route", () => { it.effect("drops cache_control breakpoints past the 4-per-request cap", () => Effect.gen(function* () { const hint = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: [ @@ -1344,7 +1342,7 @@ describe("Anthropic Messages route", () => { it.effect("spends breakpoint budget on tools before system before messages", () => Effect.gen(function* () { const hint = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, tools: [ diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 5cc8af1378..0bca4b9c5c 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -13,6 +13,7 @@ import { ToolDefinition, } from "../../src" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { AmazonBedrock } from "../../src/providers" import * as BedrockConverse from "../../src/protocols/bedrock-converse" import { it } from "../lib/effect" @@ -101,7 +102,7 @@ const baseRequest = LLM.request({ describe("Bedrock Converse route", () => { it.effect("prepares Converse target with system, inference config, and messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(baseRequest) + const prepared = yield* compileRequest(baseRequest) expect(prepared.body).toEqual({ modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", @@ -114,7 +115,7 @@ describe("Bedrock Converse route", () => { it.effect("passes topK through additionalModelRequestFields as top_k", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(baseRequest, { generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }), }), @@ -129,14 +130,14 @@ describe("Bedrock Converse route", () => { it.effect("omits additionalModelRequestFields when topK is unset", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(baseRequest) + const prepared = yield* compileRequest(baseRequest) expect(prepared.body.additionalModelRequestFields).toBeUndefined() }), ) it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], @@ -153,7 +154,7 @@ describe("Bedrock Converse route", () => { it.effect("prepares tool config with toolSpec and toolChoice", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(baseRequest, { tools: [ ToolDefinition.make({ @@ -187,7 +188,7 @@ describe("Bedrock Converse route", () => { it.effect("keeps tools and omits the unsupported choice when tool choice is none", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(baseRequest, { tools: [ ToolDefinition.make({ @@ -217,7 +218,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers assistant tool-call + tool-result message history", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_history", model, @@ -256,7 +257,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers image content in tool-result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_image", model, @@ -491,7 +492,7 @@ describe("Bedrock Converse route", () => { providerMetadata: { bedrock: { signature: "sig_1" } }, }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -546,9 +547,7 @@ describe("Bedrock Converse route", () => { }, ]) - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message], cache: "none" }), - ) + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) expect(prepared.body.messages).toEqual([ { role: "assistant", @@ -639,7 +638,7 @@ describe("Bedrock Converse route", () => { text: "", providerMetadata: { bedrock: { redactedData } }, }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -754,7 +753,7 @@ describe("Bedrock Converse route", () => { secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", }, }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") - const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed })) + const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed })) expect(prepared.route).toBe("bedrock-converse") expect(prepared.model).toBe(signed) @@ -764,7 +763,7 @@ describe("Bedrock Converse route", () => { it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_cache", model, @@ -796,7 +795,7 @@ describe("Bedrock Converse route", () => { it.effect("does not emit cachePoint when no cache hint is set", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(baseRequest) + const prepared = yield* compileRequest(baseRequest) expect(prepared.body).toMatchObject({ system: [{ text: "You are concise." }], messages: [{ role: "user", content: [{ text: "Say hello." }] }], @@ -806,7 +805,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers image media into Bedrock image blocks", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_image", model, @@ -843,7 +842,7 @@ describe("Bedrock Converse route", () => { it.effect("base64-encodes Uint8Array image bytes", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_image_bytes", model, @@ -865,7 +864,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers document media into Bedrock document blocks with format and name", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_doc", model, @@ -897,7 +896,7 @@ describe("Bedrock Converse route", () => { it.effect("requires names for document media", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })], @@ -910,7 +909,7 @@ describe("Bedrock Converse route", () => { it.effect("passes named document-only messages through for provider validation", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, cache: "none", @@ -936,7 +935,7 @@ describe("Bedrock Converse route", () => { it.effect("lowers document media in tool results", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, cache: "none", @@ -988,7 +987,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects unsupported image media types", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_bad_image", model, @@ -1002,7 +1001,7 @@ describe("Bedrock Converse route", () => { it.effect("rejects unsupported document media types", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_bad_doc", model, @@ -1017,7 +1016,7 @@ describe("Bedrock Converse route", () => { it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: [{ type: "text", text: "system", cache }], @@ -1034,7 +1033,7 @@ describe("Bedrock Converse route", () => { it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], @@ -1066,7 +1065,7 @@ describe("Bedrock Converse route", () => { it.effect("drops cachePoint markers past the 4-per-request cap", () => Effect.gen(function* () { const cache = new CacheHint({ type: "ephemeral" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: [ diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 309f5db0ff..e09e321873 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -3,7 +3,7 @@ import { ConfigProvider, Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM, LLMEvent } from "../../src" import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare" -import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" import { sseEvents } from "../lib/sse" @@ -34,7 +34,7 @@ describe("Cloudflare", () => { }) expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("cloudflare-ai-gateway") expect(prepared.body).toMatchObject({ @@ -129,7 +129,7 @@ describe("Cloudflare", () => { openai: { reasoningField: "reasoning", reasoningDetails: merged }, }) - const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] })) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged }, ]) @@ -180,7 +180,7 @@ describe("Cloudflare", () => { it.effect("allows a fully configured baseURL override", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: CloudflareAIGateway.configure({ baseURL: "https://gateway.proxy.test/v1/custom/compat", @@ -208,7 +208,7 @@ describe("Cloudflare", () => { }) expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("cloudflare-workers-ai") expect(prepared.body).toMatchObject({ diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 2b22401e0a..07325b9702 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as Gemini from "../../src/protocols/gemini" import { ProviderShared } from "../../src/protocols/shared" import { it } from "../lib/effect" @@ -26,7 +27,7 @@ const request = LLM.request({ describe("Gemini route", () => { it.effect("prepares Gemini target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ contents: [{ role: "user", parts: [{ text: "Say hello." }] }], @@ -38,12 +39,12 @@ describe("Gemini route", () => { it.effect("normalizes Gemini thinking options", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } }, }), ) - const filtered = yield* LLMClient.prepare( + const filtered = yield* compileRequest( LLMRequest.update(request, { providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } }, }), @@ -59,7 +60,7 @@ describe("Gemini route", () => { it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], @@ -75,7 +76,7 @@ describe("Gemini route", () => { it.effect("prepares multimodal user input and tool history", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -143,7 +144,7 @@ describe("Gemini route", () => { it.effect("continues media tool results as inline model input without base64 text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -188,7 +189,7 @@ describe("Gemini route", () => { it.effect("strips matching data URLs to raw base64 inlineData", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -229,7 +230,7 @@ describe("Gemini route", () => { ] as const) it.effect(`rejects ${name}`, () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), ).pipe(Effect.flip) expect(error.message).toMatch(/does not support|does not match|valid base64/) @@ -238,7 +239,7 @@ describe("Gemini route", () => { it.effect("rejects oversized image input", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [ @@ -256,7 +257,7 @@ describe("Gemini route", () => { it.effect("keeps tools and sends function calling mode NONE", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_choice_none", model, @@ -276,7 +277,7 @@ describe("Gemini route", () => { it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_schema_patch", model, @@ -457,7 +458,7 @@ describe("Gemini route", () => { response.events.findIndex((event) => event.type === "tool-call"), ) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -691,7 +692,7 @@ describe("Gemini route", () => { it.effect("rejects unsupported assistant media content", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_media", model, diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index 395d983ffe..8c031d473c 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http" import { LLM } from "../../src" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" import { deltaChunk, finishChunk } from "../lib/openai-chunks" @@ -182,7 +183,7 @@ describe("Google Vertex providers", () => { it.effect("protects the Vertex Messages API version from body overlays", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: GoogleVertexMessages.configure({ accessToken: "vertex-token", diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts index a8b40346cf..4c881d20fb 100644 --- a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts +++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts @@ -5,6 +5,7 @@ import { OpenAIChat } from "../../src/protocols/openai-chat" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenRouter from "../../src/providers/openrouter" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { recordedTests } from "../recorded-test" import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios" @@ -84,9 +85,7 @@ for (const item of cases) { ), ).toBe(true) - const replay = yield* LLMClient.prepare( - LLM.request({ model: item.model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] })) expect(replay.body.messages).toMatchObject([ { role: "assistant", content: response.text, reasoning: response.reasoning }, ]) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 57ae8e8653..27d1f296dd 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -18,6 +18,7 @@ import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" import { ProviderShared } from "../../src/protocols/shared" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" import { deltaChunk, usageChunk } from "../lib/openai-chunks" @@ -42,11 +43,7 @@ const request = LLM.request({ describe("OpenAI Chat route", () => { it.effect("prepares OpenAI Chat payload", () => Effect.gen(function* () { - // Pass the OpenAIChat payload type so `prepared.body` is statically - // typed to the route's native shape — the assertions below read field - // names without `unknown` casts. - const prepared = yield* LLMClient.prepare(request) - const _typed: { readonly model: string; readonly stream: true } = prepared.body + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "gpt-4o-mini", @@ -64,7 +61,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -87,7 +84,7 @@ describe("OpenAI Chat route", () => { it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -105,7 +102,7 @@ describe("OpenAI Chat route", () => { it.effect("writes reasoning to a configured custom field on every assistant message", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), messages: [ @@ -131,7 +128,7 @@ describe("OpenAI Chat route", () => { it.effect("rejects reasoning fields that conflict with assistant message fields", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model: Model.update(model, { compatibility: { reasoningField: "content" } }), messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], @@ -144,7 +141,7 @@ describe("OpenAI Chat route", () => { it.effect("maps OpenAI provider options to Chat options", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), prompt: "think", @@ -159,7 +156,7 @@ describe("OpenAI Chat route", () => { it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "think", @@ -253,7 +250,7 @@ describe("OpenAI Chat route", () => { it.effect("prepares assistant tool-call and tool-result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -291,7 +288,7 @@ describe("OpenAI Chat route", () => { it.effect("preserves structured tool errors for the model", () => Effect.gen(function* () { const error = { error: { type: "unknown", message: "Tool execution interrupted" } } - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -311,7 +308,7 @@ describe("OpenAI Chat route", () => { it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -355,7 +352,7 @@ describe("OpenAI Chat route", () => { it.effect("orders parallel tool responses before one aggregated vision message", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -405,7 +402,7 @@ describe("OpenAI Chat route", () => { it.effect("aggregates consecutive tool images with a following system update", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -446,7 +443,7 @@ describe("OpenAI Chat route", () => { it.effect("appends system updates without replacing multipart user content", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -474,7 +471,7 @@ describe("OpenAI Chat route", () => { ] as const) it.effect(`rejects ${name}`, () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), ).pipe(Effect.flip) expect(error.message).toMatch(/does not support|does not match|valid base64/) @@ -483,7 +480,7 @@ describe("OpenAI Chat route", () => { it.effect("rejects oversized image input", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, messages: [ @@ -501,7 +498,7 @@ describe("OpenAI Chat route", () => { it.effect("prepares raw and data URL image media as vision input", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_media", model, @@ -528,7 +525,7 @@ describe("OpenAI Chat route", () => { it.effect("lowers reasoning-only assistant history", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_reasoning", model, @@ -619,9 +616,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: field }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }]) } }), @@ -647,9 +642,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "vendor_reasoning" }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model: custom, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }]) }), ) @@ -692,9 +685,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning", reasoningDetails: details }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", @@ -737,9 +728,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: details }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }]) }), ) @@ -764,9 +753,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningField: "reasoning", reasoningDetails: details }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details }, ]) @@ -839,9 +826,7 @@ describe("OpenAI Chat route", () => { openai: { reasoningDetails: [] }, }) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }]) }), ) @@ -889,9 +874,7 @@ describe("OpenAI Chat route", () => { response.events.findIndex(LLMEvent.is.textStart), ) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([ { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged }, ]) @@ -918,9 +901,7 @@ describe("OpenAI Chat route", () => { expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) - const replay = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }]) }), ) @@ -950,7 +931,7 @@ describe("OpenAI Chat route", () => { Effect.gen(function* () { const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 } const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 } - const replay = yield* LLMClient.prepare( + const replay = yield* compileRequest( LLM.request({ model, messages: [ @@ -979,7 +960,7 @@ describe("OpenAI Chat route", () => { it.effect("retains scalar replay for mixed structured reasoning parts", () => Effect.gen(function* () { const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 } - const replay = yield* LLMClient.prepare( + const replay = yield* compileRequest( LLM.request({ model, messages: [ @@ -1004,7 +985,7 @@ describe("OpenAI Chat route", () => { it.effect("replays native scalar reasoning alongside native details", () => Effect.gen(function* () { const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }] - const replay = yield* LLMClient.prepare( + const replay = yield* compileRequest( LLM.request({ model, messages: [ diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index 6565820af8..397c64c8b3 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src" import { Auth, LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import { it } from "../lib/effect" @@ -52,7 +53,7 @@ const providerFamilies = [ describe("OpenAI-compatible Chat route", () => { it.effect("prepares generic Chat target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], toolChoice: ToolChoice.make({ type: "required" }), @@ -127,7 +128,7 @@ describe("OpenAI-compatible Chat route", () => { it.effect("matches AI SDK compatible basic request body fixture", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "deepseek-chat", @@ -145,7 +146,7 @@ describe("OpenAI-compatible Chat route", () => { it.effect("matches AI SDK compatible tool request body fixture", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_parity", model, diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index f6591f50d2..7d52b10cd1 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -7,6 +7,7 @@ import { OpenResponses } from "../../src/protocols/open-responses" import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" import { OpenAIResponses } from "../../src/protocols/openai-responses" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import { it } from "../lib/effect" import { fixedResponse } from "../lib/http" import { sseEvents } from "../lib/sse" @@ -23,7 +24,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", provider: "example", }).model("example-model") - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, system: "You are concise.", @@ -61,7 +62,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }), ).pipe(Effect.flip) @@ -76,7 +77,7 @@ describe("Open Responses-compatible route", () => { apiKey: "test-key", baseURL: "https://responses.example.test/v1", }).model("example-model") - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -102,7 +103,7 @@ describe("Open Responses-compatible route", () => { baseURL: "https://responses.example.test/v1", providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, }).model("example-model") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." })) expect(prepared.body).toMatchObject({ reasoning: { effort: "low" }, diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index a3d75a7eeb..3b9a0b76aa 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -14,6 +14,7 @@ import { Usage, } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as XAI from "../../src/providers/xai" @@ -56,7 +57,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool describe("OpenAI Responses route", () => { it.effect("prepares OpenAI Responses target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare(request) + const prepared = yield* compileRequest(request) expect(prepared.body).toEqual({ model: "gpt-4.1-mini", @@ -74,7 +75,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers the hosted OpenAI image generation tool", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "Show me a rooftop garden.", @@ -92,7 +93,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects invalid hosted image generation options locally", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ model, prompt: "Show me a rooftop garden.", @@ -109,7 +110,7 @@ describe("OpenAI Responses route", () => { Effect.gen(function* () { const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } }) expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) - const prepared = yield* LLMClient.prepare(input) + const prepared = yield* compileRequest(input) expect(prepared.body).toMatchObject({ service_tier: "priority" }) expect(prepared.body).not.toHaveProperty("serviceTier") @@ -118,7 +119,7 @@ describe("OpenAI Responses route", () => { it.effect("passes through custom OpenAI reasoning effort strings", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }), ) @@ -128,7 +129,7 @@ describe("OpenAI Responses route", () => { it.effect("omits unsupported semantic service tiers", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), ) @@ -138,7 +139,7 @@ describe("OpenAI Responses route", () => { it.effect("flattens top-level object unions in function schemas", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { tools: [ ToolDefinition.make({ @@ -191,7 +192,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -217,7 +218,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares OpenAI Responses WebSocket target", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLMRequest.update(request, { model: OpenAIResponses.webSocketRoute .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) @@ -395,7 +396,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares function call and function output input items", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result", model, @@ -432,7 +433,7 @@ describe("OpenAI Responses route", () => { content: [], structured: {}, } - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -453,7 +454,7 @@ describe("OpenAI Responses route", () => { it.effect("keeps primitive tool errors as plain text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -469,7 +470,7 @@ describe("OpenAI Responses route", () => { it.effect("keeps non-JSON tool errors as plain text", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -487,7 +488,7 @@ describe("OpenAI Responses route", () => { // image data is not JSON-stringified into `function_call_output.output`. it.effect("lowers image tool-result content as structured input_image items", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image", model, @@ -516,7 +517,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers single-image tool-result content as structured input_image array", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_image_only", model, @@ -540,7 +541,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers PDF tool-result content as structured input_file array", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_tool_result_pdf", model, @@ -575,7 +576,7 @@ describe("OpenAI Responses route", () => { it.effect("uses xAI inline file encoding for PDF tool results", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: xaiModel, messages: [ @@ -610,7 +611,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects unsupported media in tool-result content with a clear error", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_tool_result_unsupported_media", model, @@ -633,7 +634,7 @@ describe("OpenAI Responses route", () => { it.effect("prepares the composed native continuation request", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( continuationRequest({ id: "req_native_continuation_openai", model, @@ -675,7 +676,7 @@ describe("OpenAI Responses route", () => { it.effect("maps OpenAI provider options to Responses options", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), prompt: "think", @@ -700,7 +701,7 @@ describe("OpenAI Responses route", () => { it.effect("accepts the full ResponseIncludable union", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", @@ -722,7 +723,7 @@ describe("OpenAI Responses route", () => { it.effect("filters unknown includable values out of the include array", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", @@ -739,7 +740,7 @@ describe("OpenAI Responses route", () => { it.effect("treats an explicit empty include as no include at all", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }), ) @@ -749,7 +750,7 @@ describe("OpenAI Responses route", () => { it.effect("treats an all-invalid include as no include at all", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), ) @@ -759,7 +760,7 @@ describe("OpenAI Responses route", () => { it.effect("omits include when no include is set", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }), ) @@ -773,7 +774,7 @@ describe("OpenAI Responses route", () => { // reasoningSummary: "auto" by default. Without `include`, a follow-up // turn cannot replay reasoning state, so the facade also opts into // `reasoning.encrypted_content` automatically. - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), prompt: "hi", @@ -788,7 +789,7 @@ describe("OpenAI Responses route", () => { it.effect("lets callers opt out of the GPT-5 default include", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), prompt: "hi", @@ -802,7 +803,7 @@ describe("OpenAI Responses route", () => { it.effect("request OpenAI provider options override route defaults", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", @@ -934,9 +935,7 @@ describe("OpenAI Responses route", () => { }, ]) - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) expect(prepared.body.input).toEqual([ { role: "assistant", @@ -1270,7 +1269,7 @@ describe("OpenAI Responses route", () => { it.effect("preserves assistant content order around reasoning items", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_reasoning_order", model, @@ -1308,7 +1307,7 @@ describe("OpenAI Responses route", () => { it.effect("references stored reasoning items by id", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1330,7 +1329,7 @@ describe("OpenAI Responses route", () => { it.effect("references stored provider-executed hosted tool results by id", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1367,7 +1366,7 @@ describe("OpenAI Responses route", () => { it.effect("continues stateless hosted image generation with the generated image", () => Effect.gen(function* () { const imageTool = OpenAI.imageGeneration({ action: "edit" }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ @@ -1408,7 +1407,7 @@ describe("OpenAI Responses route", () => { it.effect("joins streamed summary blocks into one continuation reasoning item", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_multi_summary_continuation", model, @@ -1445,7 +1444,7 @@ describe("OpenAI Responses route", () => { it.effect("skips non-persisted reasoning ids without encrypted state", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_reasoning_without_encrypted_state", model, @@ -1762,7 +1761,7 @@ describe("OpenAI Responses route", () => { it.effect("lowers user image and PDF content", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ id: "req_media", model, @@ -1793,7 +1792,7 @@ describe("OpenAI Responses route", () => { it.effect("uses xAI inline file encoding for user PDFs", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: xaiModel, messages: [ @@ -1825,7 +1824,7 @@ describe("OpenAI Responses route", () => { it.effect("rejects unsupported user media content", () => Effect.gen(function* () { - const error = yield* LLMClient.prepare( + const error = yield* compileRequest( LLM.request({ id: "req_media", model, diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index b4ac2fe2e5..e2a61c250e 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, Message } from "../../src" import { LLMClient } from "../../src/route" +import { compileRequest } from "../../src/route/client" import * as OpenRouter from "../../src/providers/openrouter" import { it } from "../lib/effect" import { fixedResponse } from "../lib/http" @@ -19,7 +20,7 @@ describe("OpenRouter", () => { }) expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") - const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) expect(prepared.route).toBe("openrouter") expect(prepared.body).toMatchObject({ @@ -32,7 +33,7 @@ describe("OpenRouter", () => { it.effect("applies OpenRouter payload options from the model helper", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key", @@ -100,7 +101,7 @@ describe("OpenRouter", () => { { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 }, { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 }, ] - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [ @@ -133,7 +134,7 @@ describe("OpenRouter", () => { { type: "reasoning.encrypted", id: "state", data: "opaque" }, { type: "reasoning.encrypted", id: "state", data: "opaque" }, ] - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [ @@ -158,7 +159,7 @@ describe("OpenRouter", () => { { type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" }, { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" }, ] - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [ @@ -179,7 +180,7 @@ describe("OpenRouter", () => { it.effect("omits scalar reasoning without continuation details", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), messages: [Message.assistant({ type: "reasoning", text: "Thinking" })], diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index a9df815daf..4f83942a66 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -3,7 +3,8 @@ import { Effect } from "effect" import { LLM } from "../src" import { OpenAIChat } from "../src/protocols" import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" -import { Auth, LLMClient } from "../src/route" +import { Auth } from "../src/route" +import { compileRequest } from "../src/route/client" import { it } from "./lib/effect" describe("tool schema projections", () => { @@ -79,7 +80,7 @@ describe("tool schema projections", () => { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) - const prepared = yield* LLMClient.prepare( + const prepared = yield* compileRequest( LLM.request({ model, prompt: "Use the tool.", diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 87a02d1b3c..e3dfbe8d80 100644 --- a/packages/core/test/aisdk.test.ts +++ b/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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( - 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( + const prepared = yield* compileRequest( LLM.request({ model: resolved, messages: [ diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 547893a088..273c15a8c6 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/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") diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index b8f46d91af..a64f2367d5 100644 --- a/packages/core/test/session-compact.test.ts +++ b/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" })) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 89f1f18d39..aa781f40da 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/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( diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index ea12b528f2..7ca2a9b18f 100644 --- a/packages/core/test/session-generate.test.ts +++ b/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(() => { diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index b69abb77bc..6c95cdb009 100644 --- a/packages/core/test/session-title.test.ts +++ b/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(