From 16df2467fda3ddb9ad24c58b30123019ae47d60d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 24 Jul 2026 23:42:46 +0530 Subject: [PATCH] refactor(ai): name provider turn operations --- packages/ai/AGENTS.md | 6 +++--- packages/ai/DESIGN.md | 8 ++++---- packages/ai/README.md | 4 ++-- packages/ai/example/call-sites.md | 2 +- packages/ai/example/tutorial.ts | 6 +++--- packages/ai/src/llm.ts | 4 ++-- packages/ai/src/route/client.ts | 2 +- packages/ai/test/exports.test.ts | 4 ++++ packages/ai/test/provider/cloudflare.test.ts | 10 +++++----- .../provider/openai-responses-images.recorded.test.ts | 4 ++-- packages/ai/test/tool.types.ts | 6 +++--- 11 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 6c35cafc344c..69d1f40cb0c1 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(...)`, `Model.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(...)`, `Model.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.generateTurn`, `LLM.streamTurn`, and `LLM.generateObject`. Two ways to construct the same thing is one too many. ## Tests @@ -223,7 +223,7 @@ Routes lower these into provider-native assistant tool-call messages and tool-re ### Tool dispatch -`LLM.stream(request)` and `LLM.generate(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`. +`LLM.streamTurn(request)` and `LLM.generateTurn(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`. ```ts const get_weather = tool({ @@ -240,7 +240,7 @@ const get_weather = tool({ }) const tools = { get_weather, get_time, ... } -const events = yield* LLM.stream( +const events = yield* LLM.streamTurn( LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }), ).pipe(Stream.runCollect) diff --git a/packages/ai/DESIGN.md b/packages/ai/DESIGN.md index 5629c13408da..36bc9abfb0c3 100644 --- a/packages/ai/DESIGN.md +++ b/packages/ai/DESIGN.md @@ -175,8 +175,8 @@ const request = LLM.request({ prompt: "Say hello.", }) -// Current API: this performs one provider turn, despite the broad name. -const response = yield * LLM.generate(request) +// Current API: this performs one provider turn. +const response = yield * LLM.generateTurn(request) // Current API: execution also needs LLMClient.layer and RequestExecutor services. ``` @@ -432,7 +432,7 @@ const request = LLM.request({ tools: Tool.toDefinitions(tools), }) -const events = yield * LLM.stream(request).pipe(Stream.runCollect) +const events = yield * LLM.streamTurn(request).pipe(Stream.runCollect) const call = Array.from(events).find(LLMEvent.is.toolCall) if (call && !call.providerExecuted) { @@ -1076,7 +1076,7 @@ The redesign intentionally removes or changes these current concepts: | Current | Proposed | | --------------------------------------- | ----------------------------------------------------------- | | Mandatory `LLM.request({ model, ... })` | Inline calls or model-free portable requests | -| `LLM.generate` means one turn | `LLM.generate` means complete run | +| No complete-run API | Add `LLM.generate` / `LLM.stream` | | `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn | | `LLMClient.layer` requirement | Standard Effect requirements exposed directly | | Public `Route` mental model | Hidden behind executable `Model` | diff --git a/packages/ai/README.md b/packages/ai/README.md index 597ff8eaab5b..ba5865ec6d7f 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -176,7 +176,7 @@ Conversational image generation remains part of the LLM interaction. OpenAI Resp ```ts const program = Effect.gen(function* () { - const response = yield* LLM.generate( + const response = yield* LLM.generateTurn( LLM.request({ model: OpenAI.configure({ apiKey }).responses("gpt-5"), prompt: "Design a solarpunk rooftop garden, then show me.", @@ -193,7 +193,7 @@ The hosted result is represented as a provider-executed tool call and tool resul ## Public API - **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. -- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use. +- **`LLM.generateTurn` / `LLM.streamTurn`** — execute exactly one provider turn, 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. diff --git a/packages/ai/example/call-sites.md b/packages/ai/example/call-sites.md index 0b33d28c480b..934b5a124054 100644 --- a/packages/ai/example/call-sites.md +++ b/packages/ai/example/call-sites.md @@ -334,7 +334,7 @@ Final request call site stays boring: ```ts const response = yield * - LLM.generate( + LLM.generateTurn( LLM.request({ model: DeepSeek.model("deepseek-chat"), prompt: "Hello.", diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts index 7ee4abb1463b..a1e1d4d62b70 100644 --- a/packages/ai/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -65,7 +65,7 @@ const rawOverlayExample = LLM.request({ // 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* () { - const response = yield* LLM.generate(request) + const response = yield* LLM.generateTurn(request) console.log("\n== generate ==") console.log("generated text:", response.text) @@ -74,7 +74,7 @@ const generateOnce = Effect.gen(function* () { // 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want // incremental text, reasoning, tool input, usage, or finish events. -const streamText = LLM.stream(request).pipe( +const streamText = LLM.streamTurn(request).pipe( Stream.tap((event) => Effect.sync(() => { if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) @@ -106,7 +106,7 @@ const streamWithTools = Effect.gen(function* () { generation: { maxTokens: 80, temperature: 0 }, tools: Tool.toDefinitions(tools), }) - const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect)) + const events = Array.from(yield* LLM.streamTurn(request).pipe(Stream.runCollect)) for (const event of events) { if (event.type === "tool-call") console.log("tool call", event.name, event.input) if (event.type === "text-delta") process.stdout.write(event.text) diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 8b6f904a5b49..162732f7a99a 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -31,9 +31,9 @@ export type RequestInput = Omit< readonly http?: HttpOptions.Input } -export const generate = LLMClient.generate +export const generateTurn = LLMClient.generate -export const stream = LLMClient.stream +export const streamTurn = LLMClient.stream export const request = (input: RequestInput) => { const { diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 067292329be1..0a8735108eb6 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -412,7 +412,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) = ) const generateWith = (stream: Interface["stream"]) => - Effect.fn("LLM.generate")(function* (request: LLMRequest) { + Effect.fn("LLM.generateTurn")(function* (request: LLMRequest) { const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) const response = LLMResponse.complete(state) if (response) return response diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index bea36c4c3bec..91bb4fffc6ba 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -23,6 +23,10 @@ import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages describe("public exports", () => { test("root exposes app-facing runtime APIs", () => { expect(LLM.request).toBeFunction() + expect(LLM.generateTurn).toBeFunction() + expect(LLM.streamTurn).toBeFunction() + expect(LLM).not.toHaveProperty("generate") + expect(LLM).not.toHaveProperty("stream") expect(LLMClient.Service).toBeFunction() expect(LLMClient.layer).toBeDefined() expect(ImageInput.bytes).toBeFunction() diff --git a/packages/ai/test/provider/cloudflare.test.ts b/packages/ai/test/provider/cloudflare.test.ts index 309f5db0ff28..e3cd70a80799 100644 --- a/packages/ai/test/provider/cloudflare.test.ts +++ b/packages/ai/test/provider/cloudflare.test.ts @@ -47,7 +47,7 @@ describe("Cloudflare", () => { it.effect("posts to the derived gateway endpoint with bearer auth", () => Effect.gen(function* () { - const response = yield* LLM.generate( + const response = yield* LLM.generateTurn( LLM.request({ model: CloudflareAIGateway.configure({ accountId: "test-account", @@ -104,7 +104,7 @@ describe("Cloudflare", () => { index: 0, }, ] - const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + const response = yield* LLM.generateTurn(LLM.request({ model, prompt: "Say hello." })).pipe( Effect.provide( dynamicResponse((input) => Effect.succeed( @@ -150,7 +150,7 @@ describe("Cloudflare", () => { it.effect("supports authenticated AI Gateway plus upstream provider auth", () => Effect.gen(function* () { - yield* LLM.generate( + yield* LLM.generateTurn( LLM.request({ model: CloudflareAIGateway.configure({ accountId: "test-account", @@ -221,7 +221,7 @@ describe("Cloudflare", () => { it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () => Effect.gen(function* () { - const response = yield* LLM.generate( + const response = yield* LLM.generateTurn( LLM.request({ model: CloudflareWorkersAI.configure({ accountId: "test-account", @@ -256,7 +256,7 @@ describe("Cloudflare", () => { it.effect("supports direct Workers AI token aliases through auth config", () => Effect.gen(function* () { - yield* LLM.generate( + yield* LLM.generateTurn( LLM.request({ model: CloudflareWorkersAI.configure({ accountId: "test-account", diff --git a/packages/ai/test/provider/openai-responses-images.recorded.test.ts b/packages/ai/test/provider/openai-responses-images.recorded.test.ts index 41b9ab84d416..33acc61c0ee1 100644 --- a/packages/ai/test/provider/openai-responses-images.recorded.test.ts +++ b/packages/ai/test/provider/openai-responses-images.recorded.test.ts @@ -29,7 +29,7 @@ describe("OpenAI Responses image generation recorded", () => { partialImages: 0, }), ] - const response = yield* LLM.generate( + const response = yield* LLM.generateTurn( LLM.request({ model: openai.responses("gpt-5-mini"), messages: [initial], @@ -49,7 +49,7 @@ describe("OpenAI Responses image generation recorded", () => { expect(result.result.value[0].mime).toBe("image/jpeg") expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true) - const edited = yield* LLM.generate( + const edited = yield* LLM.generateTurn( LLM.request({ model: openai.responses("gpt-5-mini"), messages: [initial, response.message, Message.user("Now make the triangle blue.")], diff --git a/packages/ai/test/tool.types.ts b/packages/ai/test/tool.types.ts index 2bd33df545f9..f2ff3b1ba0fa 100644 --- a/packages/ai/test/tool.types.ts +++ b/packages/ai/test/tool.types.ts @@ -32,9 +32,9 @@ Tool.make({ ], }) -LLM.stream(request) -LLM.generate(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) })) +LLM.streamTurn(request) +LLM.generateTurn(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) })) ToolRuntime.dispatch({ executable }, { type: "tool-call", id: "call_1", name: "executable", input: { city: "Paris" } }) // @ts-expect-error High-level tool orchestration overloads are intentionally not supported. -LLM.stream({ request, tools: { schemaOnly } }) +LLM.streamTurn({ request, tools: { schemaOnly } })