Просмотр исходного кода

fix(ai): expose client service requirements (#40275)

Shoubhit Dash 1 неделя назад
Родитель
Сommit
8ce850e142

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

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

+ 2 - 2
packages/ai/src/image-client.ts

@@ -15,11 +15,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
 
 export const generate = <Options extends ImageOptions>(
   request: ImageRequestFor<Options>,
-): Effect.Effect<ImageResponse, AIError> =>
+): Effect.Effect<ImageResponse, AIError, Service> =>
   Effect.gen(function* () {
     const client = yield* Service
     return yield* client.generate(request)
-  }) as Effect.Effect<ImageResponse, AIError>
+  })
 
 export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
   Service,

+ 3 - 3
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<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
   options: GenerateObjectOptions<S, SelectedLanguageModel>,
-): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
+): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
 export function generateObject<const SelectedLanguageModel extends LanguageModel>(
   options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
-): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
+): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
 export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
   if ("schema" in options) {
     const { schema, ...rest } = options

+ 4 - 4
packages/ai/src/route/client.ts

@@ -422,18 +422,18 @@ const generateWith = (stream: Interface["stream"]) =>
     )
   })
 
-export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
+export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
   return Stream.unwrap(
     Effect.gen(function* () {
       return (yield* Service).stream(request, options)
     }),
-  ) as Stream.Stream<LLMEvent, AIError>
+  )
 }
 
-export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
+export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
   return Effect.gen(function* () {
     return yield* (yield* Service).generate(request, options)
-  }) as Effect.Effect<LLMResponse, AIError>
+  })
 }
 
 export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>

+ 10 - 0
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> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
+type Equal<A, B> = [A, B] extends [B, A] ? true : false
+type Assert<T extends true> = T
+
 type GoogleLikeOptions = {
   readonly aspectRatio?: "1:1" | "16:9"
   readonly imageSize?: "1K" | "2K"
@@ -146,6 +153,9 @@ const request = Image.request({
 })
 const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
 void typedRequest
+const generated = ImageClient.generate(request)
+type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, 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 })

+ 32 - 4
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<ExampleProviderOptions>({ id: "example" })
 
+type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
+type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R> ? R : never
+type Equal<A, B> = [A, B] extends [B, A] ? true : false
+type Assert<T extends true> = 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<Equal<Requirements<typeof generated>, LLMClientService>>
+const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
+type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, 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<Equal<Requirements<typeof generatedObject>, LLMClientService>>
+
+const generatedDynamicObject = LLM.generateObject({
+  model,
+  prompt: "Hello",
+  jsonSchema: { type: "object" },
+})
+type GenerateDynamicObjectRequirements = Assert<Equal<Requirements<typeof generatedDynamicObject>, 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<typeof model> = { example: { mode: "fast" } }
-void options
+void (options satisfies LanguageModelProviderOptions<typeof model>)
+void (true satisfies GenerateRequirements)
+void (true satisfies StreamClientRequirements)
+void (true satisfies GenerateObjectRequirements)
+void (true satisfies GenerateDynamicObjectRequirements)