mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(ai): restore direct LLM input overloads (#51211)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
03be7f385b
commit
1de648cb13
@@ -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.
|
||||
|
||||
|
||||
+12
-13
@@ -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.
|
||||
|
||||
+22
-5
@@ -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<SelectedLanguageModel extends LanguageModel = LanguageM
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
/** Run an `LLMRequest` built with `LLM.request(...)`. */
|
||||
export const generate = LLMClient.generate
|
||||
export function generate<const Model extends LanguageModel>(
|
||||
input: RequestInput<Model>,
|
||||
options?: StreamOptions,
|
||||
): Effect.Effect<LLMResponse, AIError, Service>
|
||||
export function generate(input: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service>
|
||||
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<const Model extends LanguageModel>(
|
||||
input: RequestInput<Model>,
|
||||
options?: StreamOptions,
|
||||
): Stream.Stream<LLMEvent, AIError, Service>
|
||||
export function stream(input: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service>
|
||||
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 = <const SelectedLanguageModel extends LanguageModel>(
|
||||
input: RequestInput<SelectedLanguageModel>,
|
||||
|
||||
@@ -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: <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),
|
||||
},
|
||||
image: {
|
||||
request: Image.request,
|
||||
|
||||
@@ -32,6 +32,10 @@ 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>>
|
||||
const generatedFromInput = LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
|
||||
type InputGenerateRequirements = Assert<Equal<Requirements<typeof generatedFromInput>, LLMClientService>>
|
||||
const streamedFromInput = LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
|
||||
type InputStreamRequirements = Assert<Equal<StreamRequirements<typeof streamedFromInput>, 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<typeof model> = { mode: "fast" }
|
||||
void (options satisfies LanguageModelProviderOptions<typeof model>)
|
||||
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" } })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string> = []
|
||||
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<string> = []
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user