diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 0e86f3b6590..56100667ead 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(...)`, `Message.media(...)`, `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`. The Promise `ai.llm.generate`/`ai.llm.stream` also take an `LLMRequest`: media namespaces accept raw input because their one-shot requests have no update algebra, but `LLMRequest` is the durable boundary Core builds, logs, replays, and updates. 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. +Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `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`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key. diff --git a/packages/ai/README.md b/packages/ai/README.md index 049a21c6335..88a25717310 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -9,15 +9,13 @@ import { OpenAI } from "@opencode/ai/providers" const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }) -const request = LLM.request({ - model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead - system: "You are concise.", - prompt: "Say hello in one short sentence.", - generation: { maxTokens: 40 }, -}) - const program = Effect.gen(function* () { - const response = yield* LLM.generate(request) + const response = yield* LLM.generate({ + model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead + system: "You are concise.", + prompt: "Say hello in one short sentence.", + generation: { maxTokens: 40 }, + }) console.log(response.text) }) @@ -25,7 +23,8 @@ const program = Effect.gen(function* () { await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer))) ``` -Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, +Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt +`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the @@ -72,11 +71,11 @@ helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Ef import { AI } from "@opencode/ai/promise" const ai = AI.make() -const request = ai.llm.request({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." }) -const text = await ai.llm.generate(request) +const input = { model: openai.responses("gpt-4o-mini"), prompt: "Say hello." } +const text = await ai.llm.generate(input) const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }) await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset) -for await (const event of ai.llm.stream(request)) { +for await (const event of ai.llm.stream(ai.llm.request(input))) { // LLMEvent } await ai.dispose() @@ -937,7 +936,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } }) ## 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.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use. - **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. - **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. - **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index e6a4343b59a..f28804927b1 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -1,5 +1,6 @@ -import { Effect, JsonSchema, Schema } from "effect" -import { LLMClient, Service } from "./route/client.js" +import { Effect, JsonSchema, Schema, Stream } from "effect" +import { tryRequest } from "./media-model.js" +import { LLMClient, Service, type StreamOptions } from "./route/client.js" import { GenerationOptions, HttpOptions, @@ -35,10 +36,26 @@ export type RequestInput( + input: RequestInput, + options?: StreamOptions, +): Effect.Effect +export function generate(input: LLMRequest, options?: StreamOptions): Effect.Effect +export function generate(input: RequestInput | LLMRequest, options?: StreamOptions) { + return requestEffect(input).pipe(Effect.flatMap((request) => LLMClient.generate(request, options))) +} -export const stream = LLMClient.stream +export function stream( + input: RequestInput, + options?: StreamOptions, +): Stream.Stream +export function stream(input: LLMRequest, options?: StreamOptions): Stream.Stream +export function stream(input: RequestInput | LLMRequest, options?: StreamOptions) { + return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => LLMClient.stream(request, options)))) +} + +const requestEffect = (input: RequestInput | LLMRequest) => + input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => request(input)) export const request = ( input: RequestInput, diff --git a/packages/ai/src/promise.ts b/packages/ai/src/promise.ts index 65a2c565473..fed0474fafa 100644 --- a/packages/ai/src/promise.ts +++ b/packages/ai/src/promise.ts @@ -4,8 +4,10 @@ import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js" import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js" import { LLM } from "./index.js" import { Media } from "./media.js" +import { tryRequest } from "./media-model.js" import { RequestExecutor } from "./route/executor.js" -import { AIError, InvalidRequestError, type LLMRequest } from "./schema/index.js" +import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js" +import type { RequestInput } from "./llm.js" import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js" import { Transcription, @@ -92,6 +94,8 @@ export const make = (options: Options = {}) => { }) // The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here. + const llmRequest = (input: RequestInput | LLMRequest) => + input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input)) const imageRequest = (input: ImageRequestInput | ImageRequest) => input instanceof ImageRequest ? input : Image.request(input) const videoRequest = (input: VideoRequestInput | VideoRequest) => @@ -141,8 +145,10 @@ export const make = (options: Options = {}) => { }, llm: { request: LLM.request, - generate: (request: LLMRequest, options?: RunOptions) => run(LLM.generate(request), options), - stream: (request: LLMRequest, options?: RunOptions) => iterate(LLM.stream(request), options), + generate: (input: RequestInput | LLMRequest, options?: RunOptions) => + run(Effect.flatMap(llmRequest(input), LLM.generate), options), + stream: (input: RequestInput | LLMRequest, options?: RunOptions) => + iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options), }, image: { request: Image.request, diff --git a/packages/ai/test/llm-option-types.types.ts b/packages/ai/test/llm-option-types.types.ts index 91e4d009aca..dd82998394e 100644 --- a/packages/ai/test/llm-option-types.types.ts +++ b/packages/ai/test/llm-option-types.types.ts @@ -32,6 +32,10 @@ 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>> +const generatedFromInput = LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) +type InputGenerateRequirements = Assert, LLMClientService>> +const streamedFromInput = LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } }) +type InputStreamRequirements = Assert, LLMClientService>> LLM.request({ model, @@ -40,6 +44,11 @@ LLM.request({ providerOptions: { mode: "slow" }, }) +// @ts-expect-error Direct input keeps the selected model's provider option types. +LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) +// @ts-expect-error Stream input keeps the selected model's provider option types. +LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) + const generatedObject = LLM.generateObject({ model, prompt: "Hello", @@ -70,8 +79,16 @@ const options: LanguageModelProviderOptions = { mode: "fast" } void (options satisfies LanguageModelProviderOptions) void (true satisfies GenerateRequirements) void (true satisfies StreamClientRequirements) +void (true satisfies InputGenerateRequirements) +void (true satisfies InputStreamRequirements) void (true satisfies GenerateObjectRequirements) void (true satisfies GenerateDynamicObjectRequirements) -// @ts-expect-error The promise client runs an LLMRequest, not request input. -void ai.llm.generate({ model, prompt: "Hello" }) +void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } }) +void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } }) +void ai.llm.generate(ai.llm.request({ model, prompt: "Hello" })) +void ai.llm.stream(ai.llm.request({ model, prompt: "Hello" })) +// @ts-expect-error Promise direct input keeps the selected model's provider option types. +void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) +// @ts-expect-error Promise stream input keeps the selected model's provider option types. +void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } }) diff --git a/packages/ai/test/llm.test.ts b/packages/ai/test/llm.test.ts index 4f5aca6f9ef..6e4cd27265b 100644 --- a/packages/ai/test/llm.test.ts +++ b/packages/ai/test/llm.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" -import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js" +import { Effect, Schema, Stream } from "effect" +import { CacheHint, LLM, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js" +import { OpenAI } from "../src/providers.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIResponses from "../src/protocols/openai-responses.js" import { @@ -13,6 +14,8 @@ import { ToolDefinition, ToolResultPart, } from "../src/schema/index.js" +import { fixedResponse } from "./lib/http.js" +import { sseEvents } from "./lib/sse.js" const chatRoute = OpenAIChat.route const responsesRoute = OpenAIResponses.route @@ -240,6 +243,26 @@ describe("llm constructors", () => { expect(request.messages.map((message) => message.role)).toEqual(["user", "system"]) }) + test("generates and streams from input or a prebuilt request", async () => { + const model = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).chat("gpt-4o-mini") + const layer = fixedResponse( + sseEvents({ choices: [{ delta: { content: "Hello" } }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }), + ) + const input = { model, prompt: "Say hello." } + const request = LLM.request(input) + const generated = await Effect.runPromise(LLM.generate(input).pipe(Effect.provide(layer))) + const generatedFromRequest = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(layer))) + expect(generated.text).toBe("Hello") + expect(generatedFromRequest.text).toBe(generated.text) + + const streamed = await Effect.runPromise(LLM.stream(input).pipe(Stream.runCollect, Effect.provide(layer))) + const streamedFromRequest = await Effect.runPromise( + LLM.stream(request).pipe(Stream.runCollect, Effect.provide(layer)), + ) + expect(Array.from(streamed).some(LLMEvent.is.textDelta)).toBe(true) + expect(streamedFromRequest).toEqual(streamed) + }) + test("extracts output text from response events", () => { expect( LLMResponse.text({ diff --git a/packages/ai/test/promise.test.ts b/packages/ai/test/promise.test.ts index 4a84fe55d01..1f2fb714f7e 100644 --- a/packages/ai/test/promise.test.ts +++ b/packages/ai/test/promise.test.ts @@ -119,6 +119,9 @@ describe("AI promise client", () => { const request = ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." }) const text = await ai.llm.generate(request) expect(text.text).toBe("Hello world") + expect((await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })).text).toBe( + "Hello world", + ) const image = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }) expect(image.image).toBeInstanceOf(Media.Asset) @@ -130,6 +133,11 @@ describe("AI promise client", () => { if (LLMEvent.is.textDelta(event)) deltas.push(event.text) } expect(deltas).toEqual(["Hello", " world"]) + const directDeltas: Array = [] + for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) { + if (LLMEvent.is.textDelta(event)) directDeltas.push(event.text) + } + expect(directDeltas).toEqual(deltas) const imageEvents: Array = [] for await (const event of ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })) { @@ -138,9 +146,11 @@ describe("AI promise client", () => { expect(imageEvents).toEqual(["image-partial", "image", "finish"]) expect(seen).toEqual([ + "https://openai.test/v1/chat/completions", "https://openai.test/v1/chat/completions", "https://openai.test/v1/images/generations", "https://openai.test/v1/chat/completions", + "https://openai.test/v1/chat/completions", "https://openai.test/v1/images/generations", ]) @@ -267,6 +277,12 @@ describe("AI promise client", () => { expect(failure).toBeInstanceOf(AIError) expect(failure instanceof AIError && failure.reason.http?.status).toBe(404) + const invalid = await ai.llm + // @ts-expect-error Invalid input must reject with AIError instead of throwing synchronously. + .generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] }) + .catch((error: unknown) => error) + expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest") + const controller = new AbortController() controller.abort() const aborted = await ai.llm