From 8ce850e142589cb2b1d84b18e2298bfd4cf205d7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 3 Aug 2026 17:01:32 +0530 Subject: [PATCH] fix(ai): expose client service requirements (#40275) --- packages/ai/AGENTS.md | 2 +- packages/ai/README.md | 33 +++++++++++++++++++- packages/ai/src/image-client.ts | 4 +-- packages/ai/src/llm.ts | 6 ++-- packages/ai/src/route/client.ts | 8 ++--- packages/ai/test/image.types.ts | 10 ++++++ packages/ai/test/llm-option-types.types.ts | 36 +++++++++++++++++++--- 7 files changed, 84 insertions(+), 15 deletions(-) diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 15927996f44..3fc328099ec 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -10,7 +10,7 @@ ## Conventions -Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many. +Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many. - Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness. diff --git a/packages/ai/README.md b/packages/ai/README.md index 4eb4e045ab1..6f088d2b2a4 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -3,8 +3,9 @@ Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code. ```ts -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { LLM, LLMClient } from "@opencode-ai/ai" +import { RequestExecutor } from "@opencode-ai/ai/route" import { OpenAI } from "@opencode-ai/ai/providers" const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini") @@ -20,6 +21,10 @@ const program = Effect.gen(function* () { const response = yield* LLMClient.generate(request) console.log(response.text) }) + +const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer)) + +await Effect.runPromise(program.pipe(Effect.provide(llmLayer))) ``` Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. @@ -200,6 +205,32 @@ The hosted result is represented as a provider-executed tool call and tool resul - **`Image.generate({...})`** — generate images through a provider-neutral image request and response model. - **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. +## Testing + +Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect +the requests sent by code under test: + +```ts +import { Effect } from "effect" +import { TestLLM } from "@opencode-ai/ai/testing" + +const testLLM = TestLLM.layer({ + fallback: TestLLM.text("Hello from the test model", "text-1"), +}) + +// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service. +const programWithTestClient = Effect.gen(function* () { + const result = yield* program + const test = yield* TestLLM.Service + console.log(test.requests) + return result +}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM)) +``` + +`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and +`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is +available on the yielded `TestLLM.Service`. + ## Caching Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there). diff --git a/packages/ai/src/image-client.ts b/packages/ai/src/image-client.ts index 581047e14c6..9c5f67066d4 100644 --- a/packages/ai/src/image-client.ts +++ b/packages/ai/src/image-client.ts @@ -15,11 +15,11 @@ export class Service extends Context.Service()("@opencode/Im export const generate = ( request: ImageRequestFor, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const client = yield* Service return yield* client.generate(request) - }) as Effect.Effect + }) export const layer: Layer.Layer = Layer.effect( Service, diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 97ce92bf7e2..9d1af143aed 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -1,5 +1,5 @@ import { Effect, JsonSchema, Schema } from "effect" -import { LLMClient } from "./route/client" +import { LLMClient, Service } from "./route/client" import { GenerationOptions, HttpOptions, @@ -151,10 +151,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( */ export function generateObject>( options: GenerateObjectOptions, -): Effect.Effect>, AIError> +): Effect.Effect>, AIError, Service> export function generateObject( options: GenerateObjectDynamicOptions, -): Effect.Effect, AIError> +): Effect.Effect, AIError, Service> export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) { if ("schema" in options) { const { schema, ...rest } = options diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index d35c21d0b6b..03f43d17a19 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -422,18 +422,18 @@ const generateWith = (stream: Interface["stream"]) => ) }) -export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream { +export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream { return Stream.unwrap( Effect.gen(function* () { return (yield* Service).stream(request, options) }), - ) as Stream.Stream + ) } -export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect { +export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect { return Effect.gen(function* () { return yield* (yield* Service).generate(request, options) - }) as Effect.Effect + }) } export const streamRequest = (request: LLMRequest, options?: StreamOptions) => diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts index c6274963949..3823cb39624 100644 --- a/packages/ai/test/image.types.ts +++ b/packages/ai/test/image.types.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect" import { Image, + ImageClient, ImageInput, ImageModel, type ImageModelOptions, @@ -7,8 +9,13 @@ import { type ImageRequestFor, type ImageRoute, } from "../src" +import type { Service } from "../src/image-client" import { Google, OpenAI, XAI, ZAI } from "../src/providers" +type Requirements = T extends Effect.Effect ? R : never +type Equal = [A, B] extends [B, A] ? true : false +type Assert = T + type GoogleLikeOptions = { readonly aspectRatio?: "1:1" | "16:9" readonly imageSize?: "1K" | "2K" @@ -146,6 +153,9 @@ const request = Image.request({ }) const typedRequest: ImageRequestFor = request void typedRequest +const generated = ImageClient.generate(request) +type GenerateRequirements = Assert, Service>> +void (true satisfies GenerateRequirements) // @ts-expect-error Image requests no longer expose a common count option. Image.generate({ model: openai, prompt: "A lighthouse", count: 2 }) diff --git a/packages/ai/test/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts index 49dba9b0127..c6a80581233 100644 --- a/packages/ai/test/llm-option-types.types.ts +++ b/packages/ai/test/llm-option-types.types.ts @@ -1,5 +1,11 @@ -import { Schema } from "effect" -import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src" +import { Effect, Schema, Stream } from "effect" +import { + LLM, + type LLMClientService, + type LanguageModel, + type LanguageModelProviderOptions, + type ProviderOptions, +} from "../src" import { OpenAIChat } from "../src/protocols" interface ExampleOptions { @@ -15,9 +21,19 @@ const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://example.com/v1" } }) .model({ id: "example" }) +type Requirements = T extends Effect.Effect ? R : never +type StreamRequirements = T extends Stream.Stream ? R : never +type Equal = [A, B] extends [B, A] ? true : false +type Assert = T + LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } }) +const generated = LLM.generate(LLM.request({ model, prompt: "Hello" })) +type GenerateRequirements = Assert, LLMClientService>> +const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" })) +type StreamClientRequirements = Assert, LLMClientService>> + LLM.request({ model, prompt: "Hello", @@ -25,12 +41,20 @@ LLM.request({ providerOptions: { example: { mode: "slow" } }, }) -LLM.generateObject({ +const generatedObject = LLM.generateObject({ model, prompt: "Hello", schema: Schema.Struct({ answer: Schema.String }), providerOptions: { example: { mode: "thorough" } }, }) +type GenerateObjectRequirements = Assert, LLMClientService>> + +const generatedDynamicObject = LLM.generateObject({ + model, + prompt: "Hello", + jsonSchema: { type: "object" }, +}) +type GenerateDynamicObjectRequirements = Assert, LLMClientService>> LLM.generateObject({ model, @@ -44,4 +68,8 @@ declare const generic: LanguageModel LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } }) const options: LanguageModelProviderOptions = { example: { mode: "fast" } } -void options +void (options satisfies LanguageModelProviderOptions) +void (true satisfies GenerateRequirements) +void (true satisfies StreamClientRequirements) +void (true satisfies GenerateObjectRequirements) +void (true satisfies GenerateDynamicObjectRequirements)