refactor(ai): keep LLM calls request-only in the promise client (#51180)

This commit is contained in:
Shoubhit Dash
2026-09-24 23:47:42 +05:30
committed by GitHub
parent 499c2feaa3
commit 7013e925f5
7 changed files with 19 additions and 23 deletions
+1 -1
View File
@@ -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`. 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`. 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.
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.
+3 -2
View File
@@ -72,10 +72,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 text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
const request = ai.llm.request({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
const text = await ai.llm.generate(request)
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({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
for await (const event of ai.llm.stream(request)) {
// LLMEvent
}
await ai.dispose()
+2 -1
View File
@@ -389,7 +389,8 @@ for await (const event of generation.events({ poll: { interval: 10_000 } })) {
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const text = await ai.llm.generate({ model, prompt })
const request = ai.llm.request({ model, prompt })
const text = await ai.llm.generate(request)
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
+1
View File
@@ -35,6 +35,7 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly http?: HttpOptions.Input
}
/** Run an `LLMRequest` built with `LLM.request(...)`. */
export const generate = LLMClient.generate
export const stream = LLMClient.stream
+3 -9
View File
@@ -4,10 +4,8 @@ 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, LanguageModel, LLMRequest } from "./schema/index.js"
import type { RequestInput } from "./llm.js"
import { AIError, InvalidRequestError, type LLMRequest } from "./schema/index.js"
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
import {
Transcription,
@@ -94,8 +92,6 @@ 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) =>
@@ -145,10 +141,8 @@ export const make = (options: Options = {}) => {
},
llm: {
request: LLM.request,
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
run(Effect.flatMap(llmRequest(input), LLM.generate), options),
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options),
generate: (request: LLMRequest, options?: RunOptions) => run(LLM.generate(request), options),
stream: (request: LLMRequest, options?: RunOptions) => iterate(LLM.stream(request), options),
},
image: {
request: Image.request,
@@ -6,6 +6,7 @@ import {
type LanguageModelProviderOptions,
type ProviderOptions,
} from "../src/index.js"
import { ai } from "../src/promise.js"
import { OpenAIChat } from "../src/protocols.js"
interface ExampleOptions {
@@ -71,3 +72,6 @@ void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements)
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" })
+5 -10
View File
@@ -116,7 +116,8 @@ describe("AI promise client", () => {
const seen: Array<string> = []
const ai = AI.make({ layer: executor(seen) })
const text = await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
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")
const image = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
@@ -125,7 +126,7 @@ describe("AI promise client", () => {
expect(await ai.run(image.image.bytes())).toEqual(Uint8Array.from([1, 2, 3]))
const deltas: Array<string> = []
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
for await (const event of ai.llm.stream(request)) {
if (LLMEvent.is.textDelta(event)) deltas.push(event.text)
}
expect(deltas).toEqual(["Hello", " world"])
@@ -260,22 +261,16 @@ describe("AI promise client", () => {
const ai = AI.make({ layer: executor([]) })
const failure = await ai.llm
.generate({ model: openai.responses("gpt-5"), prompt: "Hello" })
.generate(ai.llm.request({ model: openai.responses("gpt-5"), prompt: "Hello" }))
.then(() => undefined)
.catch((error: unknown) => error)
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, not throw 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
.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }, { signal: controller.signal })
.generate(ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }), { signal: controller.signal })
.then(() => "completed")
.catch(() => "aborted")
expect(aborted).toBe("aborted")