|
|
@@ -0,0 +1,1105 @@
|
|
|
+# AI Library Design
|
|
|
+
|
|
|
+> Discussion draft. This document describes the intended replacement for the
|
|
|
+> current private `@opencode-ai/llm` API. Names and exact TypeScript signatures
|
|
|
+> are illustrative until implementation, but the domain boundaries and defaults
|
|
|
+> are deliberate.
|
|
|
+
|
|
|
+## Status
|
|
|
+
|
|
|
+- Proposed package: `@opencode-ai/ai`
|
|
|
+- Initial stable domain: `LLM`
|
|
|
+- Release posture: pre-1.0, with a stable-core intent
|
|
|
+- Migration posture: clean break; do not preserve compatibility aliases
|
|
|
+- Primary audience: general-purpose TypeScript developers using Effect
|
|
|
+- Secondary audience: OpenCode and other durable agent runtimes
|
|
|
+
|
|
|
+The package name leaves room for future domains such as embeddings, images, and
|
|
|
+speech. Those domains are not part of this design and should not be forced into
|
|
|
+the LLM run/turn model.
|
|
|
+
|
|
|
+## Goals
|
|
|
+
|
|
|
+1. Make a useful model call require very little code.
|
|
|
+2. Make the default behavior good enough that most callers do not configure it.
|
|
|
+3. Let advanced callers inspect, transform, or replace every important stage.
|
|
|
+4. Keep provider quirks behind provider and protocol boundaries.
|
|
|
+5. Preserve one provider turn as an explicit primitive for durable runtimes.
|
|
|
+6. Keep serializable request data separate from process-local execution behavior.
|
|
|
+7. Make unsupported combinations fail locally with useful typed errors.
|
|
|
+8. Stay Effect-native without making package-specific service provisioning part
|
|
|
+ of every call site.
|
|
|
+
|
|
|
+## Non-goals
|
|
|
+
|
|
|
+- A global provider or model registry
|
|
|
+- Durable agent orchestration or persistence
|
|
|
+- Session history ownership
|
|
|
+- Permission handling
|
|
|
+- Cost billing or accounting guarantees
|
|
|
+- Runtime model-catalog network requests
|
|
|
+- Compatibility with the current private API
|
|
|
+- Designing embeddings, image generation, speech, or transcription now
|
|
|
+
|
|
|
+## Design Principles
|
|
|
+
|
|
|
+### Progressive disclosure
|
|
|
+
|
|
|
+The API has four layers:
|
|
|
+
|
|
|
+1. **Run a model** with `LLM.generate` or `LLM.stream`.
|
|
|
+2. **Control one provider turn** with `LLM.generateTurn` or `LLM.streamTurn`.
|
|
|
+3. **Customize execution** with model defaults, call options, hooks, and provider
|
|
|
+ configuration.
|
|
|
+4. **Author providers** with experimental provider definitions and protocols.
|
|
|
+
|
|
|
+Normal documentation should teach only the first layer initially.
|
|
|
+
|
|
|
+### Values over registries
|
|
|
+
|
|
|
+Provider definitions, configured providers, models, protocols, tools, and hooks
|
|
|
+are immutable values. Importing a provider does not register anything globally.
|
|
|
+
|
|
|
+### Portable data, local behavior
|
|
|
+
|
|
|
+Requests, messages, tool definitions, events, usage, and result projections are
|
|
|
+plain immutable data with schemas. Configured models, executable tools, hooks,
|
|
|
+and provider definitions may contain functions and Effect requirements and are
|
|
|
+not serializable.
|
|
|
+
|
|
|
+### Strong defaults, explicit overrides
|
|
|
+
|
|
|
+Defaults should make common calls correct without hiding where behavior comes
|
|
|
+from. Overrides compose in a documented order and never require patching
|
|
|
+installed dependencies.
|
|
|
+
|
|
|
+## Domain Model
|
|
|
+
|
|
|
+### Provider Definition
|
|
|
+
|
|
|
+An immutable, declarative description of a provider integration. It owns model
|
|
|
+selection, option schemas, catalog corrections, protocols, and provider-wide
|
|
|
+hooks. It is an experimental provider-authoring API.
|
|
|
+
|
|
|
+### Configured Provider
|
|
|
+
|
|
|
+A provider definition bound to deployment concerns such as credentials,
|
|
|
+endpoint, transport, and provider headers.
|
|
|
+
|
|
|
+`configure(...)` is intentionally deployment-only. It does not establish hidden
|
|
|
+generation defaults.
|
|
|
+
|
|
|
+### Model
|
|
|
+
|
|
|
+A process-local executable model value selected from a configured provider. It
|
|
|
+contains identity, capabilities, pricing metadata, provider-specific option
|
|
|
+types, reusable request-behavior defaults, and hidden execution behavior.
|
|
|
+
|
|
|
+Normal users do not need to learn the current `Route` composite. Protocol,
|
|
|
+endpoint, auth, transport, and hooks are bound behind `Model`.
|
|
|
+
|
|
|
+### Request
|
|
|
+
|
|
|
+Portable, model-independent input for a model call. It may contain system
|
|
|
+instructions, messages, tool definitions, generation controls, output intent,
|
|
|
+cache policy, and metadata. It does not contain a configured model, executable
|
|
|
+tool handlers, or hooks.
|
|
|
+
|
|
|
+### Provider Turn
|
|
|
+
|
|
|
+Exactly one request to a model provider and its normalized response. It does not
|
|
|
+execute local tools or continue the conversation.
|
|
|
+
|
|
|
+### Model Run
|
|
|
+
|
|
|
+A complete interaction consisting of one or more provider turns. A run executes
|
|
|
+local tools, appends their results, and continues until the model completes or a
|
|
|
+stopping condition matches.
|
|
|
+
|
|
|
+### TurnResult
|
|
|
+
|
|
|
+The result of exactly one provider turn.
|
|
|
+
|
|
|
+### GenerateResult
|
|
|
+
|
|
|
+The result of a complete model run. It preserves every turn, tool activity,
|
|
|
+aggregate usage, and estimated cost while exposing shortcuts to the final output.
|
|
|
+
|
|
|
+### Protocol
|
|
|
+
|
|
|
+The provider-wire contract that lowers portable requests into provider-native
|
|
|
+bodies and raises provider-native stream events into normalized turn events.
|
|
|
+Protocols are public, reusable, fully inspectable, and immutably patchable, but
|
|
|
+the entire protocol-authoring API is experimental.
|
|
|
+
|
|
|
+## Happy Path
|
|
|
+
|
|
|
+### Effect
|
|
|
+
|
|
|
+```ts
|
|
|
+import { Effect } from "effect"
|
|
|
+import { LLM } from "@opencode-ai/ai"
|
|
|
+import { OpenAI } from "@opencode-ai/ai/providers/openai"
|
|
|
+
|
|
|
+// Environment-based credentials are a provider default. No LLMClient layer is
|
|
|
+// required: the Effect exposes standard runtime dependencies directly.
|
|
|
+const model = OpenAI.model("gpt-4.1-mini")
|
|
|
+
|
|
|
+const program = Effect.gen(function* () {
|
|
|
+ const result = yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ system: "You are concise.",
|
|
|
+ prompt: "Explain Effect in one sentence.",
|
|
|
+ })
|
|
|
+
|
|
|
+ // `generate` always returns GenerateResult, even when the run has one turn.
|
|
|
+ console.log(result.text)
|
|
|
+ console.log(result.turns.length) // 1
|
|
|
+ console.log(result.usage)
|
|
|
+ console.log(result.cost) // Estimated cost, or undefined if any turn is unpriced.
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+The required Effect environment should contain standard services plus services
|
|
|
+required by tools and hooks. It should not contain an `LLMClient` wrapper service.
|
|
|
+
|
|
|
+### Current API
|
|
|
+
|
|
|
+The current README appears similarly small but omits the package-specific service
|
|
|
+and layer required at runtime:
|
|
|
+
|
|
|
+```ts
|
|
|
+// Current API: this request contains an executable model/route value.
|
|
|
+const request = LLM.request({
|
|
|
+ model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"),
|
|
|
+ prompt: "Say hello.",
|
|
|
+})
|
|
|
+
|
|
|
+// Current API: this performs one provider turn, despite the broad name.
|
|
|
+const response = yield* LLM.generate(request)
|
|
|
+
|
|
|
+// Current API: execution also needs LLMClient.layer and RequestExecutor services.
|
|
|
+```
|
|
|
+
|
|
|
+The proposal removes mandatory request construction, removes package-specific
|
|
|
+runtime provisioning, and makes `generate` mean a complete run.
|
|
|
+
|
|
|
+## Provider And Model Selection
|
|
|
+
|
|
|
+### Environment defaults
|
|
|
+
|
|
|
+```ts
|
|
|
+import { OpenAI } from "@opencode-ai/ai/providers/openai"
|
|
|
+
|
|
|
+// Open strings receive autocomplete for IDs from the generated models.dev
|
|
|
+// snapshot but continue to accept newly released and fine-tuned model IDs.
|
|
|
+const model = OpenAI.model("gpt-4.1-mini")
|
|
|
+```
|
|
|
+
|
|
|
+### Deployment configuration
|
|
|
+
|
|
|
+```ts
|
|
|
+const openai = OpenAI.configure({
|
|
|
+ apiKey,
|
|
|
+ baseURL: "https://gateway.example.com/openai/v1",
|
|
|
+ headers: {
|
|
|
+ "x-tenant": "acme",
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+const model = openai.model("gpt-4.1-mini")
|
|
|
+```
|
|
|
+
|
|
|
+`configure(...)` owns deployment concerns only:
|
|
|
+
|
|
|
+- Credentials and authentication
|
|
|
+- Base URL and deployment location
|
|
|
+- Transport selection
|
|
|
+- Provider/deployment headers
|
|
|
+- Other provider-specific connection setup
|
|
|
+
|
|
|
+It does not own temperature, maximum output tokens, cache policy, retry policy,
|
|
|
+tools, output schema, or system instructions.
|
|
|
+
|
|
|
+### Reusable model defaults
|
|
|
+
|
|
|
+```ts
|
|
|
+const model = OpenAI.model("gpt-4.1-mini", {
|
|
|
+ generation: {
|
|
|
+ temperature: 0.2,
|
|
|
+ maxTokens: 2_000,
|
|
|
+ },
|
|
|
+ cache: "auto",
|
|
|
+ provider: {
|
|
|
+ store: false,
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+The second argument may default request behavior but not prompt/history or
|
|
|
+executable tools. Call-level values override model defaults.
|
|
|
+
|
|
|
+Provider-specific options are inferred from the concrete model:
|
|
|
+
|
|
|
+```ts
|
|
|
+yield* LLM.generate({
|
|
|
+ model: OpenAI.model("gpt-4.1-mini"),
|
|
|
+ prompt: "Hello",
|
|
|
+ provider: {
|
|
|
+ store: false,
|
|
|
+ // OpenAI-specific autocomplete here; no `{ openai: ... }` nesting.
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Code choosing between providers dynamically must narrow the model before using
|
|
|
+provider-specific options. Portable generation controls remain available without
|
|
|
+narrowing.
|
|
|
+
|
|
|
+### Current API
|
|
|
+
|
|
|
+```ts
|
|
|
+// Current API mixes deployment configuration and reusable request behavior.
|
|
|
+const model = OpenAI.configure({
|
|
|
+ apiKey,
|
|
|
+ generation: { maxTokens: 160 },
|
|
|
+ providerOptions: {
|
|
|
+ openai: { store: false },
|
|
|
+ },
|
|
|
+}).model("gpt-4o-mini")
|
|
|
+```
|
|
|
+
|
|
|
+The proposal separates deployment configuration from selected-model behavior and
|
|
|
+removes provider-keyed option bags when a concrete model already identifies the
|
|
|
+provider.
|
|
|
+
|
|
|
+## Requests
|
|
|
+
|
|
|
+### Inline input
|
|
|
+
|
|
|
+```ts
|
|
|
+const result = yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ system: "You are concise.",
|
|
|
+ prompt: "Summarize this pull request.",
|
|
|
+ generation: { maxTokens: 500 },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### Reusable portable request
|
|
|
+
|
|
|
+```ts
|
|
|
+const request = LLM.request({
|
|
|
+ system: "You are concise.",
|
|
|
+ prompt: "Summarize this pull request.",
|
|
|
+ generation: { maxTokens: 500 },
|
|
|
+})
|
|
|
+
|
|
|
+// Bind process-local execution behavior only when running.
|
|
|
+const result = yield* LLM.generate({ model, request })
|
|
|
+```
|
|
|
+
|
|
|
+`LLM.request(...)` returns a plain immutable object. Use ordinary object spread
|
|
|
+to derive another request:
|
|
|
+
|
|
|
+```ts
|
|
|
+const longer = {
|
|
|
+ ...request,
|
|
|
+ generation: {
|
|
|
+ ...request.generation,
|
|
|
+ maxTokens: 1_000,
|
|
|
+ },
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+There is no `LLM.updateRequest(...)` helper and no request Schema class.
|
|
|
+
|
|
|
+### Conversation history
|
|
|
+
|
|
|
+```ts
|
|
|
+import { Message } from "@opencode-ai/ai"
|
|
|
+
|
|
|
+const request = LLM.request({
|
|
|
+ system: "You are concise.",
|
|
|
+ messages: [
|
|
|
+ Message.user("What is Effect?"),
|
|
|
+ Message.assistant("A TypeScript library for typed functional effects."),
|
|
|
+ Message.user("Why would I use it?"),
|
|
|
+ ],
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Message helpers return plain immutable data. Object literals remain valid when
|
|
|
+they satisfy the same input type.
|
|
|
+
|
|
|
+`system` stays separate from chronological messages because it is the initial
|
|
|
+privileged instruction. A chronological system message represents an instruction
|
|
|
+change at a specific point in history.
|
|
|
+
|
|
|
+## Complete Runs
|
|
|
+
|
|
|
+### Automatic local tool loop
|
|
|
+
|
|
|
+```ts
|
|
|
+import { Effect, Schema } from "effect"
|
|
|
+import { LLM, Tool } from "@opencode-ai/ai"
|
|
|
+
|
|
|
+const tools = {
|
|
|
+ getWeather: Tool.make({
|
|
|
+ description: "Get current weather for a city.",
|
|
|
+ parameters: Schema.Struct({ city: Schema.String }),
|
|
|
+ success: Schema.Struct({ forecast: Schema.String }),
|
|
|
+
|
|
|
+ // Tool service requirements and typed errors flow into LLM.generate's
|
|
|
+ // Effect environment/error model instead of being erased.
|
|
|
+ execute: ({ city }) => Weather.get(city),
|
|
|
+
|
|
|
+ // Expected domain failures need an explicit model-visible representation.
|
|
|
+ formatError: (error) => ({
|
|
|
+ type: "text",
|
|
|
+ text: `Weather lookup failed: ${error.message}`,
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+}
|
|
|
+
|
|
|
+const result = yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ prompt: "What is the weather in London?",
|
|
|
+ tools,
|
|
|
+})
|
|
|
+
|
|
|
+// The runtime advertises definitions, dispatches calls, records results, and
|
|
|
+// continues provider turns automatically.
|
|
|
+console.log(result.text)
|
|
|
+console.log(result.turns)
|
|
|
+console.log(result.toolExecutions)
|
|
|
+```
|
|
|
+
|
|
|
+The default stopping condition is equivalent to:
|
|
|
+
|
|
|
+```ts
|
|
|
+stopWhen: StopWhen.turnCount(20)
|
|
|
+```
|
|
|
+
|
|
|
+This matches the Vercel AI SDK `ToolLoopAgent` default. Reaching the limit is a
|
|
|
+successful result with `stopReason: "max-turns"`, not an Effect failure.
|
|
|
+
|
|
|
+### Custom stopping
|
|
|
+
|
|
|
+```ts
|
|
|
+const result = yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ prompt,
|
|
|
+ tools,
|
|
|
+ stopWhen: StopWhen.any(
|
|
|
+ StopWhen.turnCount(8),
|
|
|
+ StopWhen.hasToolCall("finalize"),
|
|
|
+ ),
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+`stopWhen` accepts one predicate. Composition is explicit through combinators
|
|
|
+such as `StopWhen.any`, `StopWhen.all`, and `StopWhen.not`.
|
|
|
+
|
|
|
+Successful run stop reasons are closed:
|
|
|
+
|
|
|
+```ts
|
|
|
+type RunStopReason = "completed" | "max-turns" | "stop-condition"
|
|
|
+```
|
|
|
+
|
|
|
+### Tool concurrency
|
|
|
+
|
|
|
+Independent tool calls emitted in one turn run concurrently with a bounded,
|
|
|
+configurable concurrency limit. Results are appended in deterministic emitted
|
|
|
+order. The runtime does not infer dependencies between tool calls; the model must
|
|
|
+request dependent calls in separate turns.
|
|
|
+
|
|
|
+Tools may declare an optional timeout. The overall run timeout still applies.
|
|
|
+
|
|
|
+### Current API
|
|
|
+
|
|
|
+Today callers must manually bridge every layer:
|
|
|
+
|
|
|
+```ts
|
|
|
+const request = LLM.request({
|
|
|
+ model,
|
|
|
+ prompt,
|
|
|
+ tools: Tool.toDefinitions(tools),
|
|
|
+})
|
|
|
+
|
|
|
+const events = yield* LLM.stream(request).pipe(Stream.runCollect)
|
|
|
+const call = Array.from(events).find(LLMEvent.is.toolCall)
|
|
|
+
|
|
|
+if (call && !call.providerExecuted) {
|
|
|
+ const dispatched = yield* ToolRuntime.dispatch(tools, call)
|
|
|
+ const followUp = LLM.updateRequest(request, {
|
|
|
+ messages: [
|
|
|
+ ...request.messages,
|
|
|
+ Message.assistant([call]),
|
|
|
+ Message.tool({ ...call, result: dispatched.result }),
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ // Caller must invoke the provider again and repeat the loop.
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+That explicit flow remains possible through turn APIs, but it is no longer the
|
|
|
+only tool experience.
|
|
|
+
|
|
|
+## One Provider Turn
|
|
|
+
|
|
|
+OpenCode and other durable runtimes need to own persistence, tool settlement,
|
|
|
+and continuation. They use the explicit turn API:
|
|
|
+
|
|
|
+```ts
|
|
|
+const result = yield* LLM.generateTurn({
|
|
|
+ model,
|
|
|
+ request,
|
|
|
+ // Definitions only. generateTurn never dispatches local handlers.
|
|
|
+ tools: {
|
|
|
+ getWeather: Tool.definition({
|
|
|
+ description: "Get current weather for a city.",
|
|
|
+ parameters: WeatherInput,
|
|
|
+ }),
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+// Persist the TurnResult and settle calls durably before the next turn.
|
|
|
+for (const call of result.toolCalls) {
|
|
|
+ // Application-owned dispatch and persistence.
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`generateTurn` and `streamTurn` make exactly one provider request. They never
|
|
|
+execute a local tool and never continue automatically.
|
|
|
+
|
|
|
+This separation is load-bearing:
|
|
|
+
|
|
|
+- `generate` / `stream`: complete Model Run
|
|
|
+- `generateTurn` / `streamTurn`: one Provider Turn
|
|
|
+
|
|
|
+## Portable Tool Definitions
|
|
|
+
|
|
|
+A portable request may declare serializable definitions, but executable handlers
|
|
|
+are bound at run time:
|
|
|
+
|
|
|
+```ts
|
|
|
+const request = LLM.request({
|
|
|
+ prompt: "What is the weather in London?",
|
|
|
+ tools: {
|
|
|
+ getWeather: Tool.definition({
|
|
|
+ description: "Get current weather for a city.",
|
|
|
+ parameters: WeatherInput,
|
|
|
+ }),
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+const result = yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ request,
|
|
|
+ tools: {
|
|
|
+ getWeather: Tool.make({
|
|
|
+ description: "Get current weather for a city.",
|
|
|
+ parameters: WeatherInput,
|
|
|
+ success: WeatherOutput,
|
|
|
+ execute: getWeather,
|
|
|
+ formatError,
|
|
|
+ }),
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Definitions and handlers match by record key. Before the first provider call,
|
|
|
+the runtime validates that every local definition has a compatible executable
|
|
|
+binding. Missing or incompatible bindings fail with a typed tool-binding error.
|
|
|
+
|
|
|
+Provider-hosted tools are distinct typed values:
|
|
|
+
|
|
|
+```ts
|
|
|
+const result = yield* LLM.generate({
|
|
|
+ model: OpenAI.model("gpt-4.1"),
|
|
|
+ prompt: "Find today's relevant announcements.",
|
|
|
+ tools: {
|
|
|
+ search: OpenAI.tool.webSearch({ searchContextSize: "medium" }),
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Hosted tools do not pretend to have local handlers, and callers do not inspect a
|
|
|
+`providerExecuted` boolean to decide whether dispatch is safe.
|
|
|
+
|
|
|
+## Streaming
|
|
|
+
|
|
|
+### Run stream
|
|
|
+
|
|
|
+`LLM.stream` returns an Effect `Stream<RunEvent, LLMError, Requirements>`.
|
|
|
+Run events explicitly expose orchestration boundaries:
|
|
|
+
|
|
|
+```ts
|
|
|
+const program = LLM.stream({ model, prompt, tools }).pipe(
|
|
|
+ Stream.tap((event) =>
|
|
|
+ Effect.sync(() => {
|
|
|
+ switch (event.type) {
|
|
|
+ case "run-start":
|
|
|
+ break
|
|
|
+ case "turn-start":
|
|
|
+ break
|
|
|
+ case "turn-event":
|
|
|
+ // Normalized text, reasoning, tool-call, usage, and finish events.
|
|
|
+ if (event.event.type === "text-delta") {
|
|
|
+ process.stdout.write(event.event.text)
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case "tool-start":
|
|
|
+ break
|
|
|
+ case "tool-finish":
|
|
|
+ break
|
|
|
+ case "turn-finish":
|
|
|
+ break
|
|
|
+ case "run-finish":
|
|
|
+ // Contains the same full GenerateResult returned by LLM.generate.
|
|
|
+ console.log(event.result.usage)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ Stream.runDrain,
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+Exact event tag spelling remains an implementation detail to finalize, but the
|
|
|
+algebra is settled:
|
|
|
+
|
|
|
+- A separate `RunEvent` union for run, turn, and tool lifecycle
|
|
|
+- A focused `TurnEvent` union for normalized provider output
|
|
|
+- `streamTurn` emits only `TurnEvent`
|
|
|
+- The terminal run event contains the full `GenerateResult`
|
|
|
+
|
|
|
+External cancellation remains Effect interruption. It does not fabricate a
|
|
|
+successful result with an `interrupted` stop reason.
|
|
|
+
|
|
|
+## Structured Output
|
|
|
+
|
|
|
+Structured output is an option on `generate`, not a separate operation:
|
|
|
+
|
|
|
+```ts
|
|
|
+const Weather = Schema.Struct({
|
|
|
+ city: Schema.String,
|
|
|
+ forecast: Schema.String,
|
|
|
+ highCelsius: Schema.Number,
|
|
|
+})
|
|
|
+
|
|
|
+const result = yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ prompt: "Give me today's weather for London.",
|
|
|
+ output: Weather,
|
|
|
+})
|
|
|
+
|
|
|
+// Inferred from Weather.
|
|
|
+result.output.city
|
|
|
+```
|
|
|
+
|
|
|
+The model declaration and protocol select the best reliable strategy:
|
|
|
+
|
|
|
+1. Provider-native structured output when supported and reliable
|
|
|
+2. Forced tool output when required as a compatibility fallback
|
|
|
+3. Typed unsupported-capability failure before network execution when neither is
|
|
|
+ available
|
|
|
+
|
|
|
+Advanced callers may override the strategy when exact provider semantics matter.
|
|
|
+
|
|
|
+### Current API
|
|
|
+
|
|
|
+```ts
|
|
|
+// Current API is a separate operation and always forces a synthetic tool.
|
|
|
+const result = yield* LLM.generateObject({
|
|
|
+ model,
|
|
|
+ prompt,
|
|
|
+ schema: Weather,
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+The proposal unifies generation and lets capabilities choose the strategy rather
|
|
|
+than permanently encoding one cross-provider workaround.
|
|
|
+
|
|
|
+## Model Catalog
|
|
|
+
|
|
|
+`models.dev` is the release-time source for:
|
|
|
+
|
|
|
+- Model ID suggestions
|
|
|
+- Capabilities and modalities
|
|
|
+- Context and output limits
|
|
|
+- Pricing
|
|
|
+- Other available model metadata
|
|
|
+
|
|
|
+The package ships a generated, versioned snapshot. Normal execution performs no
|
|
|
+catalog network requests.
|
|
|
+
|
|
|
+Provider definitions may correct generated metadata where protocol-specific
|
|
|
+knowledge is more accurate. Precedence is:
|
|
|
+
|
|
|
+```text
|
|
|
+models.dev snapshot
|
|
|
+ < provider-definition correction
|
|
|
+ < provider configuration override
|
|
|
+ < model-selection override
|
|
|
+ < call override
|
|
|
+```
|
|
|
+
|
|
|
+Unknown model IDs inherit only capabilities guaranteed by the selected protocol.
|
|
|
+Unsupported request capabilities fail before network execution unless the caller
|
|
|
+explicitly overrides the model declaration.
|
|
|
+
|
|
|
+## Usage And Cost
|
|
|
+
|
|
|
+`GenerateResult` aggregates normalized usage across every turn, including cache
|
|
|
+read/write usage where providers report it.
|
|
|
+
|
|
|
+It also exposes estimated cost using the generated models.dev pricing snapshot:
|
|
|
+
|
|
|
+```ts
|
|
|
+result.usage.inputTokens
|
|
|
+result.usage.outputTokens
|
|
|
+result.usage.cacheReadInputTokens
|
|
|
+result.usage.cacheWriteInputTokens
|
|
|
+
|
|
|
+result.cost?.total
|
|
|
+result.cost?.currency // e.g. "USD"
|
|
|
+```
|
|
|
+
|
|
|
+Cost is an estimate, not a billing guarantee. If reliable pricing is unavailable
|
|
|
+for any turn, aggregate run cost is unavailable rather than partial or silently
|
|
|
+zero. Per-turn metadata should retain the catalog/pricing identity used so an
|
|
|
+estimate can be explained.
|
|
|
+
|
|
|
+## Caching
|
|
|
+
|
|
|
+Prompt caching remains `"auto"` by default. The library places protocol-aware
|
|
|
+cache boundaries where explicit caching is supported and does nothing on the wire
|
|
|
+where providers cache implicitly.
|
|
|
+
|
|
|
+```ts
|
|
|
+yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ prompt,
|
|
|
+ cache: "none", // Explicit opt-out.
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Granular cache policy remains available as an advanced request option.
|
|
|
+
|
|
|
+## Retries, Timeouts, And Cancellation
|
|
|
+
|
|
|
+### Retries
|
|
|
+
|
|
|
+The default retry policy is deliberately conservative:
|
|
|
+
|
|
|
+- Retry bounded transient transport and rate-limit failures
|
|
|
+- Retry only before observable output
|
|
|
+- Never silently retry after ambiguous tool execution or other side effects
|
|
|
+- Allow each call to override or disable retry behavior
|
|
|
+
|
|
|
+Retry configuration is call-scoped only. Provider and model configuration do not
|
|
|
+silently inherit custom retry policies.
|
|
|
+
|
|
|
+### Timeouts
|
|
|
+
|
|
|
+```ts
|
|
|
+yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ prompt,
|
|
|
+ timeout: "2 minutes", // Entire run, including tools.
|
|
|
+ turnTimeout: "30 seconds", // Each provider turn.
|
|
|
+ tools,
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Exact Duration input spelling follows Effect conventions. Individual tools may
|
|
|
+also declare optional timeouts.
|
|
|
+
|
|
|
+### Cancellation
|
|
|
+
|
|
|
+- Effect API: fiber interruption
|
|
|
+- Promise API: `AbortSignal`, rejecting with a recognizable abort error
|
|
|
+- Cancellation is not a successful run stop reason
|
|
|
+
|
|
|
+## Hooks
|
|
|
+
|
|
|
+Stable high-level hooks exist at five named stages:
|
|
|
+
|
|
|
+1. Canonical request
|
|
|
+2. Provider-native body
|
|
|
+3. Prepared transport request
|
|
|
+4. Normalized event
|
|
|
+5. Error
|
|
|
+
|
|
|
+Hooks are Effectful. They may transform the stage value or fail with a typed
|
|
|
+error. They may not secretly short-circuit execution, synthesize a response,
|
|
|
+retry, or redirect control flow.
|
|
|
+
|
|
|
+```ts
|
|
|
+const model = OpenAI.model("gpt-4.1", {
|
|
|
+ hooks: {
|
|
|
+ request: (request) => Effect.succeed({
|
|
|
+ ...request,
|
|
|
+ metadata: { ...request.metadata, tenant: "acme" },
|
|
|
+ }),
|
|
|
+ body: (body, context) => auditBody(body, context),
|
|
|
+ transport: (request) => signInternalGatewayRequest(request),
|
|
|
+ event: (event) => redactProviderMetadata(event),
|
|
|
+ error: (error) => classifyInternalError(error),
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Hook scopes compose in this order:
|
|
|
+
|
|
|
+```text
|
|
|
+provider-definition hooks -> model hooks -> call hooks
|
|
|
+```
|
|
|
+
|
|
|
+Each hook sees the prior hook's output. Replacement requires an explicit
|
|
|
+definition-level patch, not accidental last-writer-wins semantics.
|
|
|
+
|
|
|
+Provider-definition hooks are authored by provider integrations. They are not
|
|
|
+passed through `Provider.configure(...)`, which remains deployment-only.
|
|
|
+
|
|
|
+## HTTP And Provider Escape Hatches
|
|
|
+
|
|
|
+The request customization ladder is:
|
|
|
+
|
|
|
+1. Portable generation controls
|
|
|
+2. Model-typed `provider` options
|
|
|
+3. Stable staged hooks
|
|
|
+4. Serializable HTTP/body overlays
|
|
|
+5. Experimental provider-definition or protocol patching
|
|
|
+
|
|
|
+```ts
|
|
|
+yield* LLM.generate({
|
|
|
+ model,
|
|
|
+ prompt,
|
|
|
+ http: {
|
|
|
+ headers: { "x-experimental": "1" },
|
|
|
+ query: { debug: "true" },
|
|
|
+ body: { newlyReleasedProviderField: true },
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Raw overlays are intentional last-resort support for provider features that ship
|
|
|
+before the library has a typed option.
|
|
|
+
|
|
|
+## Provider-Native Metadata
|
|
|
+
|
|
|
+Normalized message/content/event unions remain closed and exhaustive. Unknown or
|
|
|
+provider-required round-trip data lives in caller-writable `providerMetadata`.
|
|
|
+
|
|
|
+```ts
|
|
|
+const assistant = Message.assistant([
|
|
|
+ {
|
|
|
+ type: "reasoning",
|
|
|
+ text: "...",
|
|
|
+ providerMetadata: {
|
|
|
+ openai: {
|
|
|
+ // Opaque provider data needed for replay or continuation.
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+])
|
|
|
+```
|
|
|
+
|
|
|
+Protocols validate metadata they consume. The field is an escape hatch, not a
|
|
|
+portable semantic guarantee.
|
|
|
+
|
|
|
+## Error Model
|
|
|
+
|
|
|
+The Effect error channel is a tagged domain union rather than one `LLMError`
|
|
|
+wrapper with nested reasons. Illustrative categories:
|
|
|
+
|
|
|
+```ts
|
|
|
+type LLMError =
|
|
|
+ | AuthenticationError
|
|
|
+ | InvalidRequestError
|
|
|
+ | UnsupportedCapabilityError
|
|
|
+ | ToolBindingError
|
|
|
+ | TransportError
|
|
|
+ | ProviderResponseError
|
|
|
+ | InvalidProviderOutputError
|
|
|
+ | HookError
|
|
|
+```
|
|
|
+
|
|
|
+Each error retains relevant provider/model/turn/stage context and its underlying
|
|
|
+cause where available.
|
|
|
+
|
|
|
+Expected tool errors keep their own typed error channel. `Tool.make` requires an
|
|
|
+explicit mapping before such errors become model-visible tool results. Expected
|
|
|
+mapped failures let the model recover; defects and interruption fail the run.
|
|
|
+
|
|
|
+## Observability
|
|
|
+
|
|
|
+The core library emits Effect-native spans and metrics for:
|
|
|
+
|
|
|
+- Model runs
|
|
|
+- Provider turns
|
|
|
+- Provider requests
|
|
|
+- Retries
|
|
|
+- Tool executions
|
|
|
+
|
|
|
+Default telemetry records metadata only:
|
|
|
+
|
|
|
+- Provider and model identity
|
|
|
+- Timing
|
|
|
+- Token/cache usage
|
|
|
+- Estimated cost availability
|
|
|
+- Finish and stop reasons
|
|
|
+- Retry counts
|
|
|
+- Tool names
|
|
|
+
|
|
|
+Prompts, model output, tool arguments, and tool results are never recorded by
|
|
|
+default. Explicit hooks or telemetry configuration may opt into content capture.
|
|
|
+
|
|
|
+## Promise API
|
|
|
+
|
|
|
+Promise wrappers live at a separate subpath so the root remains unambiguously
|
|
|
+Effect-first:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { LLM } from "@opencode-ai/ai/promise"
|
|
|
+import { OpenAI } from "@opencode-ai/ai/providers/openai"
|
|
|
+
|
|
|
+const result = await LLM.generate({
|
|
|
+ model: OpenAI.model("gpt-4.1-mini"),
|
|
|
+ prompt: "Explain Effect in one sentence.",
|
|
|
+ signal: abortController.signal,
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Streaming returns an `AsyncIterable<RunEvent>`:
|
|
|
+
|
|
|
+```ts
|
|
|
+for await (const event of LLM.stream({ model, prompt, signal })) {
|
|
|
+ if (event.type === "turn-event" && event.event.type === "text-delta") {
|
|
|
+ process.stdout.write(event.event.text)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Top-level Promise functions use a default runtime for built-in services. Custom
|
|
|
+Effect service requirements use a configured client:
|
|
|
+
|
|
|
+```ts
|
|
|
+const client = LLM.makeClient({
|
|
|
+ layer: Layer.mergeAll(WeatherLive, AuditLive),
|
|
|
+})
|
|
|
+
|
|
|
+const result = await client.generate({ model, prompt, tools })
|
|
|
+```
|
|
|
+
|
|
|
+The Promise API mirrors Effect semantics. It does not invent different run,
|
|
|
+error, stopping, or cancellation behavior.
|
|
|
+
|
|
|
+## Schemas
|
|
|
+
|
|
|
+Schemas live in a dedicated namespace/subpath instead of flooding root exports:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { LLMSchema } from "@opencode-ai/ai/schema"
|
|
|
+
|
|
|
+const request = yield* Schema.decodeUnknown(LLMSchema.Request)(input)
|
|
|
+```
|
|
|
+
|
|
|
+Schemas cover only serializable domain values:
|
|
|
+
|
|
|
+- Requests and messages
|
|
|
+- Portable tool definitions
|
|
|
+- Turn and run events
|
|
|
+- Serializable result projections
|
|
|
+- Usage and cost estimates
|
|
|
+- Tagged errors where serializable
|
|
|
+- Provider metadata containers
|
|
|
+
|
|
|
+Configured models, executable tools, hooks, provider definitions, and protocols
|
|
|
+are process-local behavior and do not receive fake serialization schemas.
|
|
|
+
|
|
|
+## Provider Authoring
|
|
|
+
|
|
|
+Provider authoring is public but experimental.
|
|
|
+
|
|
|
+### Declarative provider definition
|
|
|
+
|
|
|
+```ts
|
|
|
+import {
|
|
|
+ Provider,
|
|
|
+ Protocol,
|
|
|
+} from "@opencode-ai/ai/provider"
|
|
|
+
|
|
|
+export const ExampleAI = Provider.define({
|
|
|
+ id: "example",
|
|
|
+ options: ExampleProviderOptions,
|
|
|
+ configure: configureExampleDeployment,
|
|
|
+ protocols: {
|
|
|
+ responses: ExampleResponses,
|
|
|
+ },
|
|
|
+ models: ({ deployment, catalog }) => ({
|
|
|
+ model: (id, defaults) =>
|
|
|
+ Provider.model({
|
|
|
+ id,
|
|
|
+ deployment,
|
|
|
+ protocol: ExampleResponses,
|
|
|
+ metadata: catalog.model(id),
|
|
|
+ defaults,
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+ catalog: generatedExampleCatalog,
|
|
|
+ corrections: exampleCatalogCorrections,
|
|
|
+ hooks: exampleProviderHooks,
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+The exact builder fields need implementation design, but it must remain one
|
|
|
+declarative immutable object, infer provider option types, and support `.with(...)`
|
|
|
+patching. It must not register globally.
|
|
|
+
|
|
|
+Built-ins export their immutable definition for advanced forking:
|
|
|
+
|
|
|
+```ts
|
|
|
+import { OpenAI } from "@opencode-ai/ai/providers/openai"
|
|
|
+
|
|
|
+const PatchedOpenAI = OpenAI.definition.with({
|
|
|
+ protocols: {
|
|
|
+ responses: OpenAI.protocols.responses.with({
|
|
|
+ // Explicit immutable stage patch.
|
|
|
+ body: {
|
|
|
+ fromRequest: patchResponsesBody,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### Protocols
|
|
|
+
|
|
|
+A protocol exposes all native types and stages:
|
|
|
+
|
|
|
+- Provider-native request body and schema
|
|
|
+- Transport frame type
|
|
|
+- Provider-native event and schema
|
|
|
+- Parser state
|
|
|
+- Request lowering
|
|
|
+- Event stepping
|
|
|
+- Terminal detection and final flushing
|
|
|
+
|
|
|
+Every stage is immutably patchable. This is deliberately more open than the AI
|
|
|
+SDK integrations that motivated this package.
|
|
|
+
|
|
|
+```ts
|
|
|
+const PatchedResponses = OpenAIResponses.with({
|
|
|
+ body: {
|
|
|
+ fromRequest: (request) =>
|
|
|
+ OpenAIResponses.body.fromRequest(request).pipe(
|
|
|
+ Effect.map((body) => ({ ...body, custom_field: true })),
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ stream: {
|
|
|
+ step: patchResponsesStep,
|
|
|
+ },
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+Protocol body, frame, native event, and parser-state types are exported. Because
|
|
|
+provider wire formats change often, these types and patch APIs are explicitly
|
|
|
+experimental and do not receive the high-level API's compatibility promise.
|
|
|
+
|
|
|
+## Package Surface
|
|
|
+
|
|
|
+Illustrative export layout:
|
|
|
+
|
|
|
+```text
|
|
|
+@opencode-ai/ai
|
|
|
+ LLM
|
|
|
+ Message
|
|
|
+ Tool
|
|
|
+ StopWhen
|
|
|
+ stable domain types
|
|
|
+
|
|
|
+@opencode-ai/ai/promise
|
|
|
+ Promise/AsyncIterable LLM facade
|
|
|
+
|
|
|
+@opencode-ai/ai/schema
|
|
|
+ serializable domain schemas
|
|
|
+
|
|
|
+@opencode-ai/ai/provider
|
|
|
+ experimental Provider and Protocol authoring APIs
|
|
|
+
|
|
|
+@opencode-ai/ai/providers/openai
|
|
|
+@opencode-ai/ai/providers/anthropic
|
|
|
+@opencode-ai/ai/providers/google
|
|
|
+...
|
|
|
+```
|
|
|
+
|
|
|
+Providers are imported through individual subpaths. The root does not export all
|
|
|
+providers, and there is no preferred all-providers barrel.
|
|
|
+
|
|
|
+## Defaults
|
|
|
+
|
|
|
+| Concern | Default |
|
|
|
+| --- | --- |
|
|
|
+| `LLM.generate` semantics | Complete Model Run |
|
|
|
+| `LLM.generateTurn` semantics | Exactly one Provider Turn |
|
|
|
+| Maximum turns | 20 |
|
|
|
+| Turn-limit outcome | Successful `max-turns` result |
|
|
|
+| Tool execution | Automatic in runs |
|
|
|
+| Tool concurrency | Concurrent, bounded, deterministic result order |
|
|
|
+| Prompt caching | `auto` |
|
|
|
+| Retries | Conservative, pre-output transient failures only |
|
|
|
+| Structured output | Capability-selected native or tool strategy |
|
|
|
+| Capability mismatch | Typed failure before network execution |
|
|
|
+| Unknown model capability | Conservative protocol baseline |
|
|
|
+| Telemetry content | Metadata only |
|
|
|
+| Cost | Estimated aggregate or unavailable |
|
|
|
+| Cancellation | Interruption/rejection, never successful completion |
|
|
|
+
|
|
|
+## Clean-break Migration
|
|
|
+
|
|
|
+The redesign intentionally removes or changes these current concepts:
|
|
|
+
|
|
|
+| Current | Proposed |
|
|
|
+| --- | --- |
|
|
|
+| `@opencode-ai/llm` | `@opencode-ai/ai` |
|
|
|
+| Mandatory `LLM.request({ model, ... })` | Inline calls or model-free portable requests |
|
|
|
+| `LLM.generate` means one turn | `LLM.generate` means complete run |
|
|
|
+| `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` |
|
|
|
+| `Provider.make` structural helper | Experimental declarative `Provider.define` |
|
|
|
+| Schema classes as canonical values | Plain immutable values plus schema subpath |
|
|
|
+| `LLM.updateRequest` | Object spread |
|
|
|
+| `Tool.toDefinitions` in normal calls | Named executable tool records |
|
|
|
+| Manual `ToolRuntime.dispatch` loop | Automatic run dispatch; explicit turn API for orchestration |
|
|
|
+| `providerOptions: { openai: ... }` | Model-typed `provider: ...` |
|
|
|
+| `generateObject` | Typed `output` option on `generate` |
|
|
|
+| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions |
|
|
|
+| `providerExecuted` dispatch check | Distinct hosted-tool constructors |
|
|
|
+| One wrapped `LLMError` | Tagged domain error union |
|
|
|
+
|
|
|
+OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable
|
|
|
+prompt admission, persistence, permission, tool settlement, and continuation
|
|
|
+boundaries. It should not use the automatic run API for Session orchestration.
|
|
|
+
|
|
|
+## Remaining Implementation-level Questions
|
|
|
+
|
|
|
+These do not reopen the main design:
|
|
|
+
|
|
|
+1. Exact `RunEvent` and `TurnEvent` tag names and payloads
|
|
|
+2. Exact `GenerateResult` shortcut fields for text, reasoning, output, and messages
|
|
|
+3. Exact Provider definition TypeScript shape needed for strong inference
|
|
|
+4. Exact protocol `.with(...)` patch syntax and replacement semantics
|
|
|
+5. Exact Duration input fields and names
|
|
|
+6. Exact models.dev generation pipeline and correction-file format
|
|
|
+7. Exact cost representation and decimal arithmetic strategy
|
|
|
+8. Exact default retry schedule and bounded tool concurrency number
|
|
|
+9. Whether request-level serializable HTTP overlays belong in the stable schema
|
|
|
+10. Which tagged errors are serializable versus process-local
|
|
|
+
|
|
|
+These should be resolved with call-site sketches and implementation spikes rather
|
|
|
+than by changing the domain boundaries above.
|