Compare commits

..

11 Commits

Author SHA1 Message Date
Kit Langton c633d10e74 test(v2): simplify storage contract setup 2026-05-20 20:57:11 -04:00
Kit Langton 178840489f docs(v2): note storage database bootstrap cleanup 2026-05-20 20:53:20 -04:00
Kit Langton 71423b9a58 refactor(v2): keep test database setup explicit 2026-05-20 20:49:11 -04:00
Kit Langton b3d6f93148 refactor(v2): share effect sqlite database layer 2026-05-20 20:41:57 -04:00
Kit Langton dcbd244dd7 refactor(v2): use effect sqlite session storage 2026-05-20 20:34:02 -04:00
Kit Langton dc55ece384 refactor(v2): move session storage under storage 2026-05-20 20:12:30 -04:00
Kit Langton 8b38c9b999 refactor(v2): inline memory storage state 2026-05-20 20:12:30 -04:00
Kit Langton 2743504e60 refactor(v2): simplify memory storage layer 2026-05-20 20:12:30 -04:00
Kit Langton 479b49ffa6 refactor(v2): make memory storage layer constant 2026-05-20 20:12:30 -04:00
Kit Langton 110d4091e5 test(v2): isolate session storage contract 2026-05-20 20:12:30 -04:00
Kit Langton e674c242e1 feat(v2): add session storage service 2026-05-20 20:12:30 -04:00
105 changed files with 3400 additions and 3078 deletions
+2
View File
@@ -429,12 +429,14 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
"aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
"aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
"x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
"x86_64-linux": "sha256-1KQFagCMMfSdZJLPAr0b17V66Z2ITcaQis4Pa2jC1hE=",
"aarch64-linux": "sha256-DWhmkYpa9ArqzfPdmmNFkaiOw5+DllEBHESU54T/aQA=",
"aarch64-darwin": "sha256-egfIey1y2wVbvxueiI4S9IPl6IvfVpJvvj3h4B2nkxA=",
"x86_64-darwin": "sha256-22Rezk0MiDIeT4qeeT/iosDaEX1l2kn6B7/eNphT678="
}
}
+37 -43
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `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's namespace, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for the request-shaped call API: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.model`, `LLM.updateRequest`, `LLM.generateObject`. Two ways to construct the same thing is one too many.
## Tests
@@ -21,22 +21,13 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
Primary in-repo integration point:
- `packages/opencode/src/session/llm.ts` is the session-owned orchestration layer that decides whether a request uses AI SDK or this package's native route runtime.
- `packages/opencode/src/session/llm/native-request.ts` is the lowering adapter from opencode's session/AI SDK-shaped data into this package's `LLMRequest` model.
- `packages/opencode/src/session/llm/native-runtime.ts` is the execution adapter that calls `LLMClient.stream(...)` and bridges opencode tools into this package's tool runtime.
- `packages/opencode/src/session/llm/ai-sdk.ts` keeps the default AI SDK path compatible by converting AI SDK stream parts into this package's shared `LLMEvent`s.
Keep this package independent of session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in `packages/opencode/src/session/llm.ts` and its local adapters.
### Request Flow
The intended callsite is:
```ts
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"),
model: OpenAI.model("gpt-4o-mini", { apiKey }),
system: "You are concise.",
prompt: "Say hello.",
})
@@ -44,7 +35,7 @@ const request = LLM.request({
const response = yield * LLMClient.generate(request)
```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
@@ -55,8 +46,8 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
A route is the registered, runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Endpoint`** (`src/route/endpoint.ts`) — path construction. The host always lives on `model.baseURL`; the endpoint just supplies the path. `Endpoint.path("/chat/completions")` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Routes read `model.apiKey` at request time via `Auth.bearer` (the default; sets `Authorization: Bearer <apiKey>`) or `Auth.apiKeyHeader(name)` for providers that use a custom header (Anthropic `x-api-key`, Gemini `x-goog-api-key`). Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
Compose them via `Route.make(...)`:
@@ -66,52 +57,55 @@ export const route = Route.make({
id: "openai-chat",
provider: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", {
baseURL: "https://api.openai.com/v1",
transport: HttpTransport.httpJson({
endpoint: Endpoint.path("/chat/completions"),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
}),
auth: Auth.bearer(),
framing: Framing.sse,
defaults: {
baseURL: "https://api.openai.com/v1",
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
},
})
```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `Model` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `LLMError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport` — `WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport` — `WebSocketTransport.json(...)` constructs a transport whose `prepare` builds a WebSocket URL and message and whose `frames` yields decoded text from the socket. Same protocol, different transport.
### URL Construction
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) require configuration before execution.
`model.baseURL` is required; `Endpoint` only carries the path. Each protocol's `Route.make` includes a canonical URL in `defaults.baseURL` (e.g. `https://api.openai.com/v1`); provider helpers can override by passing `baseURL` in their input. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) set `baseURL: string` (required) on their input type so TypeScript catches a missing host at the call site.
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper computes `baseURL` at model construction time. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
### Provider Facades
### Provider Definitions
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
Provider-facing APIs are defined with `Provider.make(...)` from `src/provider.ts`:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
const model = openai.responses("gpt-4o-mini")
export const provider = Provider.make({
id: ProviderID.make("openai"),
model: responses,
apis: { responses, chat },
})
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
const deployment = azure.responses("my-deployment")
const gateway = CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey })
const proxied = gateway.model("openai/gpt-4o-mini")
export const model = provider.model
export const apis = provider.apis
```
Keep provider facades small and explicit:
Keep provider definitions small and explicit:
- Use only `id`, `model`, and optional `apis` in `Provider.make(...)`.
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, and `chat`.
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
- Use `model` for the default API path and `apis` for named provider-native alternatives such as OpenAI `responses` versus `chat`.
- Do not add author-facing `kind`, `version`, or `routes` fields.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
Built-in providers are namespace modules from `src/providers/index.ts`, so aliases like `OpenAI.model(...)`, `OpenAI.responses(...)`, and `OpenAI.apis.chat(...)` are fine. External provider packages should default-export the `Provider.make(...)` result and may add named aliases if useful.
### Folder layout
@@ -119,7 +113,7 @@ Keep provider facades small and explicit:
packages/llm/src/
schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy
options.ts Generation/Provider/Http options, Capabilities, Limits, ModelRef
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
errors.ts error reasons, LLMError, ToolFailure
@@ -151,12 +145,12 @@ packages/llm/src/
providers/
openai-compatible.ts generic compatible helper + family model helpers
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
azure.ts / amazon-bedrock.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed tool() helper
tool-runtime.ts implementation helpers for LLMClient tool execution
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
The dependency arrow points down: `providers/*.ts` files import `protocols`, `endpoint`, `auth`, and `framing`; protocols do not import provider metadata. Lower-level modules know nothing about specific providers.
### Shared protocol helpers
@@ -251,14 +245,14 @@ Use this order for every protocol module:
5. Request body construction (`fromRequest`)
6. Stream parsing (`step` and per-event handlers)
7. Protocol and route
8. Protocol route export
8. Model helper
### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `request-finish` (or `provider-error`) when a `terminal` event arrives. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `request-finish` event for a completed response. Use `stream.terminal` to signal the run is over and have `step` emit the final event.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance.
+13 -15
View File
@@ -7,7 +7,7 @@ import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/llm"
import { OpenAI } from "@opencode-ai/llm/providers"
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY })
const request = LLM.request({
model,
@@ -28,10 +28,10 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
- **`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.
- **`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.
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors.
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams.
## Caching
@@ -92,19 +92,17 @@ Normalized cache usage is read back into `response.usage.cacheReadInputTokens` a
## Providers
Provider facades configure endpoint/auth/deployment details first, then expose model selectors that take only a model or deployment id. The selected model carries the executable route value used at runtime.
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults.
```ts
import { OpenAI, CloudflareAIGateway } from "@opencode-ai/llm/providers"
import { Anthropic } from "@opencode-ai/llm/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
const gateway = CloudflareAIGateway.configure({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN,
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
const model = Anthropic.model("claude-sonnet-4-6", {
apiKey: process.env.ANTHROPIC_API_KEY,
})
```
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
## Provider options & HTTP overlays
@@ -114,15 +112,15 @@ Three escape hatches in order of stability:
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Route/provider defaults are overridden by request-level values for each axis.
Model-level defaults are overridden by request-level values for each axis.
## Routes
Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
Adding a new model or deployment is usually 5–15 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail.
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
-591
View File
@@ -1,591 +0,0 @@
# LLM Call Site Sketches
Scratchpad for examples first, abstractions second. Current direction: routes
execute, provider facades organize configured route sets, and models carry route
values directly.
## Conversation Summary
Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI
SDK transform path and into `packages/llm` where possible. The goal is not a big
generic transform layer; the goal is small composable route definitions backed by
recorded golden tests.
Things to keep testing against:
- Cache placement: `cache: "auto"`, manual cache breakpoints, provider cache usage.
- Images: golden image tests for providers/protocols that claim image support.
- Reasoning: canonical reasoning parts/events versus provider-native knobs.
- Auth: bearer, custom headers, multiple credentials, query auth, SigV4, OAuth, no auth.
- OpenAI-compatible providers: DeepSeek, Together, Groq, Alibaba/DashScope, custom routers.
- Provider switching: stale signatures, encrypted reasoning, provider metadata, incompatible parts.
- Error quality: typed errors instead of generic SDK/server failures.
## Final Guide: Routes Execute, Providers Organize
Do not introduce a first-class `Deployment` abstraction unless it gains real
semantics. Provider facades are ergonomic configured route groups, not execution
registries. The executable/composable thing is still a route. Do not make route
construction publish to a global registry; models should carry their route value
directly.
Keep durable identity separate from runtime capability:
- Durable identity is small serializable data like `{ providerID, modelID }` for
config, sessions, logs, and catalogs.
- Runtime capability is a `Model` with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas.
- If persisted identity needs to become executable, resolve it through an app
boundary first. Do not make `LLMRequest` recover behavior from a global route
side table.
Keep unconfigured behavior values as values, not factories. A transport like
`HttpTransport.sseJson` should be a reusable immutable value. Use a function only
when the caller supplies options or when construction needs fresh state.
Use constants to remove repetition before inventing abstractions. Provider ids
are branded once per provider facade and reused across routes; a plain exported
object is enough for the provider-facing API unless a helper earns its keep by
removing repeated route projection.
Expose default configured provider instances, and put provider-specific setup on
`.configure(...)`. Model selectors stay pure: `model(id)`, `responses(id)`,
`chat(id)`, etc. Endpoint/auth/resource/api-version configuration happens before
model selection, not as a second argument to model selection.
Use provider/product facades consistently:
- One coherent provider/product config surface gets one top-level facade.
- APIs/model kinds that share that config are methods on the facade.
- Different products with different required config get separate top-level
facades, not a shared namespace with unrelated children.
- Default facades are exposed only when concrete defaults or lazy env/credential
defaults make the facade valid.
Examples:
```ts
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }).model("openai/gpt-4o")
CloudflareWorkersAI.configure({ accountId, apiKey }).model("@cf/meta/llama-3.1-8b-instruct")
OpenAICompatible.configure({
provider: "custom",
baseURL: "https://custom.example/v1",
auth: Auth.bearer(apiKey),
}).model("custom-model")
```
Standardize the provider facade contract before abstracting construction. A
plain object is enough at first; add a helper only if repeated route projection
starts hiding the real provider-specific config.
`Route.with(...)` patch semantics should be boring and explicit:
- Omitted fields inherit from the original route.
- `endpoint` patches merge with the existing endpoint, so overriding `baseURL`
keeps the existing `path`.
- `endpoint.query` merges by default; later values win.
- `auth` replaces.
- `headers` merge by default; undefined values are omitted.
- `id` is optional in patches. Route ids are diagnostic/provider API labels, not
global runtime registry keys.
1. **Route**
- route id
- provider id
- protocol
- body schema
- body builder
- stream event schema
- parser/state machine
- transport
- method / IO shape
- framing
- request preparation
- constants when unconfigured; functions only when configured
- endpoint
- base URL
- static path
- body/model-derived path
- query params
- auth
- bearer
- custom header
- multiple credentials
- SigV4
- none
- defaults
- headers
- generation defaults
- provider options
- limits
2. **Provider Facade**
- default configured provider instance
- provider-specific `.configure(...)`
- plain object/function facade over one or more routes
- top-level export only when it represents one coherent config surface
- no passive `Provider.make(...)` wrapper unless it gains runtime behavior
3. **Model Selector**
- route/provider-owned selector
- accepts model id only
- returns executable models
- does not accept endpoint/auth/deployment overrides
4. **Model**
- model id
- route value
- provider id
- configured route value at selection time
5. **LLM Request**
- model
- messages/tools
- generation/cache/reasoning/response-format options
- request-level HTTP overlays for per-request headers/query/body additions,
not provider endpoint/auth reconfiguration
6. **Compile**
- read route from model
- merge route defaults and request overrides
- build final URL from route endpoint
- apply auth from the configured route
- build body with protocol
- execute with transport and parse with protocol
## Provider Facade Shape
The provider abstraction is a facade over configured routes, not the runtime
execution mechanism:
```ts
type ProviderFacade<APIs, Config> = {
readonly id: ProviderID
readonly model: (id: string) => Model
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs
```
Manual construction is fine and should be the default until duplication earns a
helper:
```ts
export const OpenAI = {
id: openAIProvider,
model: openAIResponses.model,
responses: openAIResponses.model,
chat: openAIChat.model,
configure: configureOpenAI,
} satisfies ProviderFacade<
{
responses: (id: string) => Model
chat: (id: string) => Model
},
OpenAIConfig
>
```
If several providers repeat the same projection from route values to model
methods, the helper can stay deliberately tiny:
```ts
const configureOpenAI = (input: OpenAIConfig = {}) =>
Provider.define({
id: openAIProvider,
routes: {
responses: openAIResponses.with(openAIConfig(input)),
chat: openAIChat.with(openAIConfig(input)),
},
default: "responses",
configure: configureOpenAI,
})
export const OpenAI = configureOpenAI()
```
`Provider.define(...)` would only project route methods and preserve types:
```ts
OpenAI.model("gpt-4o")
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
OpenAI.configure({ apiKey }).responses("gpt-4o")
```
It must not register routes, select routes dynamically, or participate in
execution. Execution still reads the route value carried by the model.
## Ideal Call Sites
Define concrete routes for a native provider, then project them through a
provider facade:
```ts
const openAIProvider = ProviderID.make("openai")
const openAIResponses = Route.make({
id: "openai-responses",
provider: openAIProvider,
protocol: OpenAIResponses.protocol,
transport: HttpTransport.sseJson,
endpoint: {
baseURL: "https://api.openai.com/v1",
path: "/responses",
},
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIChat = Route.make({
id: "openai-chat",
provider: openAIProvider,
protocol: OpenAIChat.protocol,
transport: HttpTransport.sseJson,
endpoint: {
baseURL: "https://api.openai.com/v1",
path: "/chat/completions",
},
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIResponsesWebSocket = openAIResponses.with({
id: "openai-responses-websocket",
transport: WebSocketTransport.json,
})
const openAIConfig = (input: OpenAIConfig) => ({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
headers: {
"OpenAI-Organization": input.organization,
"OpenAI-Project": input.project,
},
})
const configureOpenAI = (input: OpenAIConfig = {}) => {
const responses = openAIResponses.with(openAIConfig(input))
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
const chat = openAIChat.with(openAIConfig(input))
return {
id: openAIProvider,
responses: responses.model,
responsesWebSocket: responsesWebSocket.model,
chat: chat.model,
model: responses.model,
configure: configureOpenAI,
}
}
export const OpenAI = configureOpenAI()
```
Specialize it functionally for concrete providers:
```ts
const deepSeekProvider = ProviderID.make("deepseek")
const deepseekChat = openAIChat.with({
id: "deepseek-chat",
provider: deepSeekProvider,
endpoint: {
baseURL: "https://api.deepseek.com/v1",
},
auth: Auth.envBearer("DEEPSEEK_API_KEY"),
})
const configureDeepSeek = (input: OpenAICompatibleConfig = {}) => {
const route = deepseekChat.with({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
})
return {
id: deepSeekProvider,
model: route.model,
configure: configureDeepSeek,
}
}
export const DeepSeek = {
id: deepSeekProvider,
model: deepseekChat.model,
configure: configureDeepSeek,
}
```
Provider-specific configuration happens before model selection:
```ts
const deepseek = DeepSeek.configure({
endpoint: {
baseURL: "https://proxy.example.com/v1",
},
auth: Auth.bearer(apiKey),
})
const model = deepseek.model("deepseek-chat")
```
Final request call site stays boring:
```ts
const response =
yield *
LLM.generate(
LLM.request({
model: DeepSeek.model("deepseek-chat"),
prompt: "Hello.",
}),
)
```
HTTP versus WebSocket is represented as named route selectors, not as model or
request overrides. Same protocol, different transport, different route:
```ts
OpenAI.responses("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
```
The client should not require a different public layer just because a selected
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
capabilities available; routes that do not need WebSocket simply never touch it.
If a WebSocket route is selected in an environment without WebSocket support,
fail with a typed transport configuration error.
Azure is a route specialization with auth/path/default changes plus input
mapping. The public API configures the Azure resource once, then selects
deployment ids with pure model selectors:
```ts
const azureProvider = ProviderID.make("azure")
const azureResponses = openAIResponses.with({
id: "azure-openai-responses",
provider: azureProvider,
auth: Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
})
const configureAzure = (input: AzureConfig = {}) => {
const route = azureResponses.with({
endpoint: {
baseURL:
input.baseURL ??
Endpoint.envBaseURL(
"AZURE_RESOURCE_NAME",
(resourceName) => `https://${resourceName}.openai.azure.com/openai/v1`,
),
query: { "api-version": input.apiVersion ?? "v1" },
},
auth: input.apiKey ? Auth.header("api-key", input.apiKey) : Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
})
return {
id: azureProvider,
model: route.model,
responses: route.model,
configure: configureAzure,
}
}
export const Azure = configureAzure()
const azure = Azure.configure({
resourceName: "my-resource",
apiVersion: "v1",
})
const model = azure.responses("my-deployment")
```
Default provider facades are only valid when required configuration has a lazy
default source. `Azure.responses("my-deployment")` can be valid if endpoint
resolution reads `AZURE_RESOURCE_NAME` lazily and fails with a typed
configuration error when missing. If a provider has no sensible lazy default,
do not expose a default model selector; expose only a configured entrypoint.
Cloudflare AI Gateway and Workers AI are separate product facades because their
configuration surfaces differ. Do not make a root `Cloudflare.configure(...)`
pretend there is one coherent Cloudflare provider configuration:
```ts
const cloudflareProvider = ProviderID.make("cloudflare-ai-gateway")
const cloudflareOpenAIChat = openAIChat.with({
id: "cloudflare-ai-gateway-openai-chat",
provider: cloudflareProvider,
auth: Auth.bearerHeader("cf-aig-authorization").andThen(Auth.bearer()),
})
const configureCloudflareAIGateway = (input: CloudflareAIGatewayConfig) => {
const route = cloudflareOpenAIChat.with({
endpoint: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${input.accountId}/${input.gatewayId}/openai`,
},
auth: Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)),
})
return {
id: cloudflareProvider,
model: (modelID: string) => route.model({ id: modelID }),
configure: configureCloudflareAIGateway,
}
}
export const CloudflareAIGateway = {
id: cloudflareProvider,
configure: configureCloudflareAIGateway,
}
const gateway = CloudflareAIGateway.configure({
accountId: "account",
gatewayId: "gateway",
gatewayApiKey,
apiKey,
})
const model = gateway.model("openai/gpt-4o")
```
If a Cloudflare product gains a full lazy env default, it can expose a direct
selector too. Until then, omitting `CloudflareAIGateway.model(...)` makes missing
account/gateway configuration unrepresentable.
opencode's dynamic runtime should construct executable models at its app
boundary instead of exposing a giant unstructured public model constructor or a
generic dynamic resolver:
```ts
const model =
providerID === "azure"
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
: endpoint.websocket
? OpenAI.responsesWebSocket(apiModelID)
: OpenAI.responses(apiModelID)
```
That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. Transport selection belongs there too: map metadata like
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use
the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes
the route carried by the model.
## Competitive Shape
This follows the strongest parts of adjacent libraries:
- AI SDK: configured provider instances expose provider-specific model methods.
- Effect AI: executable models carry provider requirements and can be resolved by
an app boundary.
- LiteLLM/opencode config: dynamic `providerID/modelID` branching belongs at the
app boundary, not in the typed public provider API or a global runtime
resolver.
- LangChain/LlamaIndex: constructor-style config plus model id is convenient,
but we avoid making model selection also configure endpoint/auth.
The chosen split is:
```txt
Route = execution mechanics
Provider facade = configured route group
Model = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call
```
## What This Removes
- No `Provider.make(...)` as a core abstraction.
- No `Provider.make(...)` wrapper just to bind an id to model functions. Use a
branded provider id constant and a plain exported provider facade.
- No `Deployment.define(...)` unless future examples force it.
- No global route registry as the normal execution path.
- No import side effects required before a model can execute.
- No duplicate `provider.id` object when selected models already carry provider
id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first.
- No transport override on model/request. HTTP SSE versus WebSocket is a named
route selector such as `responses` versus `responsesWebSocket`.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `Model`; durable model
identity stays separate and cannot execute on its own.
## Implementation Todo
- [x] Replace the current executable `ModelRef` with `Model`.
- [x] Change `Model.route` to carry a route value, not a `RouteID` string.
- [ ] Keep a separate durable model identity type for persisted/session/catalog
data, likely `{ providerID, modelID }`, and make it clear that it cannot
execute without resolver context.
- [x] Change route model selectors so `route.model(id)` returns an executable
model with the route value attached, not a globally registered route id.
- [x] Remove the standalone `Route.model(route, defaults, mapInput)` helper;
configured route instances own model selection.
- [x] Remove endpoint/auth escape hatches from route model selection; callers must
configure endpoint/auth through `route.with(...)` or provider facades before
calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels.
- [x] Model endpoint as `{ baseURL, path, query }` on routes, then remove the
current split where host/query live on the model and path lives in route
transport setup.
- [x] Define `Route.with(...)` with explicit patch semantics for endpoint merge,
query merge, header merge, auth replacement, and optional diagnostic id.
- [x] Make unconfigured transports reusable constants such as
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
state construction.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
exposes available transport capabilities and selected routes fail with typed
transport config errors when a required capability is missing.
- [x] Convert OpenAI provider APIs to provider-facade shape:
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
`.responsesWebSocket(id)`.
- [x] Convert Azure to a configured facade where resource/base URL/api version
setup happens before selecting deployment ids.
- [x] Split Cloudflare products into separate facades such as
`CloudflareAIGateway` and `CloudflareWorkersAI`; do not expose a shared root
config surface unless one product actually exists.
- [x] Migrate remaining built-in provider facades one at a time so configuration
happens before model selection and selectors accept only ids:
xAI, GitHub Copilot, OpenRouter, OpenAI-compatible families, Anthropic,
Google/Gemini, and Amazon Bedrock now use configured facades such as
`Provider.configure(options).model(id)` with named selectors where needed.
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
or three provider conversions; start with plain objects if duplication is not
yet painful.
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
executable models at the session boundary with explicit provider facade
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
named route selector.
- [ ] Update tests so direct route/provider tests assert route values are carried
by executable models, and opencode/native tests assert boundary-based route
selection.
- [ ] Remove compatibility exports or stale docs only after internal call sites
are migrated; do not keep duplicate constructor paths without an external
compatibility need.
## Open Questions
- Default facades with required setup: should providers like Azure and Bedrock
expose default model selectors only when all required setup has lazy env or
credential-chain defaults? If not, omit the default selector so missing config
is impossible at the type/API level.
- Lazy endpoint/auth values: should `Endpoint.envBaseURL(...)` and env-backed
auth produce typed configuration/authentication errors at compile/prepare time
or only when executing the transport?
- `Route.with(...)` clearing semantics: endpoint/query/header patches merge by
default, but what is the explicit way to remove an inherited value?
- Provider facade helper: keep plain objects until duplication hurts, or add a
tiny `Provider.define(...)` immediately to enforce shape and method projection?
- Auth shape: should auth stay as today's composable `Auth`, or split into an
auth placement/strategy and credential sources?
- Naming: is `baseURL` still the right endpoint field name, or should it be
`origin` / `urlPrefix` to clarify that route `path` is appended?
+16 -20
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, ProviderID, Tool } from "@opencode-ai/llm"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { LLM, LLMClient, Provider, ProviderID, Tool, type ProviderModelOptions } from "@opencode-ai/llm"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/llm/route"
import { OpenAI } from "@opencode-ai/llm/providers"
/**
@@ -18,18 +18,18 @@ const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol
// choice, capabilities, deployment options, authentication, and defaults.
const model = OpenAI.configure({
const model = OpenAI.model("gpt-4o-mini", {
apiKey,
generation: { maxTokens: 160 },
providerOptions: {
openai: { store: false },
},
}).model("gpt-4o-mini")
})
// 2. Build a provider-neutral request. This is useful when reusing one request
// across generate and stream examples.
//
// Options can live on both the configured route/provider facade and the request:
// Options can live on both the model and the request:
//
// - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences.
@@ -39,7 +39,7 @@ const model = OpenAI.configure({
// - `http`: last-resort serializable overlays for final request body, headers,
// and query params. Prefer typed `providerOptions` when a field is stable.
//
// Route/provider options are defaults. Request options override them for this call.
// Model options are defaults. Request options override them for this call.
const request = LLM.request({
model,
system: "You are concise and practical.",
@@ -193,22 +193,19 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
// axes that the protocol deliberately does not know: URL, auth, and framing.
const FakeAdapter = Route.make({
id: "fake-echo",
provider: "fake-echo",
protocol: FakeProtocol,
endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
endpoint: Endpoint.path("/v1/echo"),
auth: Auth.passthrough,
framing: Framing.sse,
})
// A provider module exports a configured facade. Configuration happens before
// model selection; model selectors accept ids only.
const FakeEcho = {
// A provider module exports a Provider definition. The default `model` helper
// sets provider identity, protocol id, and the route id resolved by the registry.
const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" })
const FakeEcho = Provider.make({
id: ProviderID.make("fake-echo"),
configure: () => ({
id: ProviderID.make("fake-echo"),
model: (id: string) => FakeAdapter.model({ id }),
}),
}
model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }),
})
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
// body conversion, validation, endpoint, auth, and HTTP construction without
@@ -216,7 +213,7 @@ const FakeEcho = {
const inspectFakeProvider = Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: FakeEcho.configure().model("tiny-echo"),
model: FakeEcho.model("tiny-echo"),
prompt: "Show me the provider pipeline.",
}),
)
@@ -230,8 +227,7 @@ const inspectFakeProvider = Effect.gen(function* () {
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
// or tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.defaultLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const program = Effect.gen(function* () {
// yield* generateOnce
@@ -241,6 +237,6 @@ const program = Effect.gen(function* () {
// yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
Effect.runPromise(program)
+1 -1
View File
@@ -97,7 +97,7 @@ const markMessages = (
}
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
+2 -1
View File
@@ -1,4 +1,4 @@
export { LLMClient } from "./route/client"
export { LLMClient, modelLimits, modelRef } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export type {
@@ -6,6 +6,7 @@ export type {
RouteRoutedModelInput,
Interface as LLMClientShape,
Service as LLMClientService,
ModelRefInput,
} from "./route/client"
export * from "./schema"
export { Tool, ToolFailure, toDefinitions, tool } from "./tool"
+6 -3
View File
@@ -1,5 +1,5 @@
import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient } from "./route/client"
import { LLMClient, modelLimits, modelRef, type ModelRefInput } from "./route/client"
import {
GenerationOptions,
HttpOptions,
@@ -9,7 +9,6 @@ import {
LLMRequest,
LLMResponse,
Message,
type ModelInput as SchemaModelInput,
SystemPart,
ToolChoice,
ToolDefinition,
@@ -19,7 +18,7 @@ import {
} from "./schema"
import { make as makeTool, type ToolSchema } from "./tool"
export type ModelInput = SchemaModelInput
export type ModelInput = ModelRefInput
export type MessageInput = Message.Input
@@ -43,6 +42,10 @@ export type RequestInput = Omit<
readonly http?: HttpOptions.Input
}
export const limits = modelLimits
export const model = modelRef
export const generate = LLMClient.generate
export const stream = LLMClient.stream
@@ -386,7 +386,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096,
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
@@ -452,8 +452,8 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic: {
...left.providerMetadata?.["anthropic"],
...right.providerMetadata?.["anthropic"],
...(left.providerMetadata?.["anthropic"] ?? {}),
...(right.providerMetadata?.["anthropic"] ?? {}),
},
},
})
@@ -673,12 +673,19 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
provider: "anthropic",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
endpoint: Endpoint.path(PATH),
auth: Auth.apiKeyHeader("x-api-key"),
framing: Framing.sse,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = Route.model(route, {
provider: "anthropic",
baseURL: DEFAULT_BASE_URL,
})
export * as AnthropicMessages from "./anthropic-messages"
+54 -36
View File
@@ -1,5 +1,5 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client"
import { Route, type RouteModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol"
import {
@@ -14,7 +14,7 @@ import {
} from "../schema"
import { BedrockEventStream } from "./bedrock-event-stream"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache"
import { BedrockMedia } from "./utils/bedrock-media"
import { Lifecycle } from "./utils/lifecycle"
@@ -24,6 +24,23 @@ const ADAPTER = "bedrock-converse"
export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth"
// =============================================================================
// Public Model Input
// =============================================================================
export type BedrockConverseModelInput = RouteModelInput & {
/**
* Bearer API key (Bedrock's newer API key auth). Sets the `Authorization`
* header and bypasses SigV4 signing. Mutually exclusive with `credentials`.
*/
readonly apiKey?: string
/**
* AWS credentials for SigV4 signing. The route signs each request at
* `toHttp` time using `aws4fetch`. Mutually exclusive with `apiKey`.
*/
readonly credentials?: BedrockCredentials
readonly headers?: Record<string, string>
}
// =============================================================================
// Request Body Schema
// =============================================================================
@@ -44,7 +61,6 @@ type BedrockToolUseBlock = Schema.Schema.Type<typeof BedrockToolUseBlock>
const BedrockToolResultContentItem = Schema.Union([
Schema.Struct({ text: Schema.String }),
Schema.Struct({ json: Schema.Unknown }),
BedrockMedia.ImageBlock,
])
const BedrockToolResultBlock = Schema.Struct({
@@ -245,33 +261,15 @@ const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
},
})
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
if (part.result.type === "text" || part.result.type === "error")
return [{ text: ProviderShared.toolResultText(part) }]
if (part.result.type === "json") return [{ json: part.result.value }]
const content: Array<Schema.Schema.Type<typeof BedrockToolResultContentItem>> = []
for (const item of part.result.value) {
if (item.type === "text") {
content.push({ text: item.text })
continue
}
const media = yield* BedrockMedia.lower(item)
if (!("image" in media))
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
content.push(media)
}
return content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
return {
toolResult: {
toolUseId: part.id,
content: yield* lowerToolResultContent(part),
status: part.result.type === "error" ? "error" : "success",
},
} satisfies BedrockToolResultBlock
const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({
toolResult: {
toolUseId: part.id,
content:
part.result.type === "text" || part.result.type === "error"
? [{ text: ProviderShared.toolResultText(part) }]
: [{ json: part.result.value }],
status: part.result.type === "error" ? "error" : "success",
},
})
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
@@ -333,7 +331,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(yield* lowerToolResult(part))
content.push(lowerToolResult(part))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
@@ -599,11 +597,11 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
provider: "bedrock",
protocol,
// Bedrock's URL embeds the region in the route endpoint host and the
// validated modelId in the path. We read the validated body so the URL
// matches the body that gets signed.
// Bedrock's URL embeds the region in the host (set on `model.baseURL` by
// the provider helper from credentials) and the validated modelId in the
// path. We read the validated body so the URL matches the body that gets
// signed.
endpoint: Endpoint.path<BedrockConverseBody>(
({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
),
@@ -611,6 +609,26 @@ export const route = Route.make({
framing,
})
export const sigV4Auth = BedrockAuth.sigV4
export const nativeCredentials = BedrockAuth.nativeCredentials
const bedrockModel = Route.model(
route,
{
provider: "bedrock",
},
{
mapInput: (input: BedrockConverseModelInput) => {
const { credentials, ...rest } = input
const region = credentials?.region ?? "us-east-1"
return {
...rest,
baseURL: rest.baseURL ?? `https://bedrock-runtime.${region}.amazonaws.com`,
native: nativeCredentials(input.native, credentials),
}
},
},
)
export const model = bedrockModel
export * as BedrockConverse from "./bedrock-converse"
+10 -5
View File
@@ -404,14 +404,19 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
provider: "google",
protocol,
// Gemini's path embeds the model id and pins SSE framing at the URL level.
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`),
auth: Auth.apiKeyHeader("x-goog-api-key"),
framing: Framing.sse,
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = Route.model(route, {
provider: "google",
baseURL: DEFAULT_BASE_URL,
})
export * as Gemini from "./gemini"
+17 -3
View File
@@ -2,6 +2,7 @@ import { Array as Arr, Effect, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
@@ -392,15 +393,28 @@ export const protocol = Protocol.make({
},
})
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIChatBody))
export const httpTransport = HttpTransport.httpJson({
endpoint: Endpoint.path(PATH),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
})
export const route = Route.make({
id: ADAPTER,
provider: "openai",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
transport: httpTransport,
defaults: {
baseURL: DEFAULT_BASE_URL,
},
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = route.model
export * as OpenAIChat from "./openai-chat"
@@ -5,14 +5,16 @@ import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat"
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
export type OpenAICompatibleChatModelInput = Omit<RouteRoutedModelInput, "baseURL"> & {
readonly baseURL: string
}
/**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
* overrides only the route id so providers can be resolved per-family without
* colliding with native OpenAI. Provider helpers configure the route endpoint
* before model selection.
* colliding with native OpenAI. The model carries the host on `baseURL`,
* supplied by whichever profile/provider helper builds it.
*/
export const route = Route.make({
id: ADAPTER,
@@ -21,4 +23,6 @@ export const route = Route.make({
framing: Framing.sse,
})
export const model = Route.model<OpenAICompatibleChatModelInput>(route)
export * as OpenAICompatibleChat from "./openai-compatible-chat"
+39 -45
View File
@@ -2,11 +2,11 @@ import { Effect, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { HttpTransport, WebSocketTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
type MediaPart,
Usage,
type FinishReason,
type LLMRequest,
@@ -31,12 +31,6 @@ const OpenAIResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"),
text: Schema.String,
})
const OpenAIResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
})
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
const OpenAIResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
@@ -45,7 +39,7 @@ const OpenAIResponsesOutputText = Schema.Struct({
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputText) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
Schema.Struct({
type: Schema.tag("function_call"),
@@ -157,15 +151,12 @@ const OpenAIResponsesEvent = Schema.Struct({
item_id: Schema.optional(Schema.String),
item: Schema.optional(OpenAIResponsesStreamItem),
response: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
usage: optionalNull(OpenAIResponsesUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: Schema.optional(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
usage: optionalNull(OpenAIResponsesUsage),
}),
),
code: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
@@ -205,22 +196,6 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
arguments: ProviderShared.encodeJson(part.input),
})
const imageUrl = (part: MediaPart) =>
typeof part.data === "string" && part.data.startsWith("data:")
? part.data
: `data:${part.mediaType};base64,${ProviderShared.mediaBytes(part)}`
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media" && part.mediaType.startsWith("image/")) {
return { type: "input_image" as const, image_url: imageUrl(part) }
}
if (part.type === "media") return yield* invalid("OpenAI Responses user media content only supports images")
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
@@ -228,7 +203,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
for (const message of request.messages) {
if (message.role === "user") {
input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
const content: TextPart[] = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text"]))
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text"])
content.push(part)
}
input.push({ role: "user", content: content.map((part) => ({ type: "input_text", text: part.text })) })
continue
}
@@ -555,18 +536,27 @@ export const protocol = Protocol.make({
},
})
const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
const auth = Auth.none
const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesBody))
const transportBase = {
endpoint: Endpoint.path<OpenAIResponsesBody>(PATH),
auth: Auth.bearer(),
encodeBody,
}
const routeDefaults = {
baseURL: DEFAULT_BASE_URL,
}
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const httpTransport = HttpTransport.httpJson({
...transportBase,
framing: Framing.sse,
})
export const route = Route.make({
id: ADAPTER,
provider: "openai",
protocol,
endpoint,
auth,
transport: httpTransport,
defaults: routeDefaults,
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
@@ -579,10 +569,8 @@ const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
})
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
OpenAIResponsesBody,
OpenAIResponsesWebSocketMessage
>({
export const webSocketTransport = WebSocketTransport.json({
...transportBase,
toMessage: webSocketMessage,
encodeMessage: encodeWebSocketMessage,
})
@@ -591,9 +579,15 @@ export const webSocketRoute = Route.make({
id: `${ADAPTER}-websocket`,
provider: "openai",
protocol,
endpoint,
auth,
transport: webSocketTransport,
defaults: routeDefaults,
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = route.model
export const webSocketModel = webSocketRoute.model
export * as OpenAIResponses from "./openai-responses"
+7 -2
View File
@@ -11,7 +11,6 @@ import {
type MediaPart,
type ToolResultPart,
} from "../schema"
export { isRecord } from "../utils/record"
export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json)
@@ -20,6 +19,13 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/**
* Plain-record narrowing. Excludes arrays so routes checking nested JSON
* Schema fragments don't accidentally treat a tuple as a key/value bag.
*/
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -126,7 +132,6 @@ export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
if (part.result.type === "content") return encodeJson(part.result.value)
return encodeJson(part.result.value)
}
@@ -1,14 +1,15 @@
import { AwsV4Signer } from "aws4fetch"
import { Effect } from "effect"
import { Effect, Option, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth"
import type { LLMRequest } from "../../schema"
import { ProviderShared } from "../shared"
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth
* via `model.apiKey`, which bypasses SigV4 signing. STS-vended credentials
* should be refreshed by the consumer (rebuild the model) before they expire;
* the route does not refresh.
*/
export interface Credentials {
readonly region: string
@@ -17,6 +18,32 @@ export interface Credentials {
readonly sessionToken?: string
}
const NativeCredentials = Schema.Struct({
accessKeyId: Schema.String,
secretAccessKey: Schema.String,
region: Schema.optional(Schema.String),
sessionToken: Schema.optional(Schema.String),
})
const decodeNativeCredentials = Schema.decodeUnknownOption(NativeCredentials)
export const region = (request: LLMRequest) => {
const fromNative = request.model.native?.aws_region
if (typeof fromNative === "string" && fromNative !== "") return fromNative
return (
decodeNativeCredentials(request.model.native?.aws_credentials).pipe(
Option.map((credentials) => credentials.region),
Option.getOrUndefined,
) ?? "us-east-1"
)
}
const credentialsFromInput = (request: LLMRequest): Credentials | undefined =>
decodeNativeCredentials(request.model.native?.aws_credentials).pipe(
Option.map((creds) => ({ ...creds, region: creds.region ?? region(request) })),
Option.getOrUndefined,
)
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -44,27 +71,33 @@ const signRequest = (input: {
),
})
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (credentials: Credentials | undefined) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!credentials) {
return yield* ProviderShared.invalidRequest(
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
)
}
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
body: input.body,
headers: headersForSigning,
credentials,
})
return Headers.setAll(headersForSigning, signed)
})
/**
* Bedrock auth. `model.apiKey` (Bedrock's newer Bearer API key auth) wins if
* set; otherwise sign the exact JSON bytes with SigV4 using credentials from
* `model.native.aws_credentials`.
*/
export const auth = Auth.custom((input: AuthInput) => {
if (input.request.model.apiKey) return Auth.toEffect(Auth.bearer())(input)
return Effect.gen(function* () {
const credentials = credentialsFromInput(input.request)
if (!credentials) {
return yield* ProviderShared.invalidRequest(
"Bedrock Converse requires either model.apiKey or AWS credentials in model.native.aws_credentials",
)
}
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({ url: input.url, body: input.body, headers: headersForSigning, credentials })
return Headers.setAll(headersForSigning, signed)
})
})
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
export const auth = sigV4(undefined)
export const nativeCredentials = (native: Record<string, unknown> | undefined, credentials: Credentials | undefined) =>
credentials
? {
...native,
aws_credentials: credentials,
aws_region: credentials.region,
}
: native
export * as BedrockAuth from "./bedrock-auth"
+7 -13
View File
@@ -1,20 +1,14 @@
import type { RouteDefaultsInput } from "./route/client"
import type { Model, ModelID, ProviderID } from "./schema"
import type { RouteModelInput } from "./route/client"
import type { ModelID, ModelRef, ProviderID } from "./schema"
export type ModelOptions = RouteDefaultsInput
export type ModelOptions = Omit<RouteModelInput, "id">
/**
* Advanced structural provider definition helper. Built-in providers should
* prefer explicit `configure(options).model(id)` facades so deployment config is
* chosen before model selection. The optional `apis` map remains for external
* structural providers that expose multiple route selectors behind one provider.
*/
export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
id: string | ModelID,
options?: Options,
) => Model
) => ModelRef
type AnyModelFactory = (...args: never[]) => Model
type AnyModelFactory = (...args: never[]) => ModelRef
export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
readonly id: ProviderID
@@ -24,8 +18,8 @@ export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
type DefinitionShape = {
readonly id: ProviderID
readonly model: (...args: never[]) => Model
readonly apis?: Record<string, (...args: never[]) => Model>
readonly model: (...args: never[]) => ModelRef
readonly apis?: Record<string, (...args: never[]) => ModelRef>
}
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
+28 -23
View File
@@ -1,12 +1,12 @@
import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import { Route, type RouteModelInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as BedrockConverse from "../protocols/bedrock-converse"
import type { BedrockCredentials } from "../protocols/bedrock-converse"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL"> & {
readonly apiKey?: string
readonly headers?: Record<string, string>
readonly credentials?: BedrockCredentials
@@ -15,29 +15,34 @@ export type Config = RouteDefaultsInput & {
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string
}
type ModelInput = ModelOptions & Pick<RouteModelInput, "id">
export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return BedrockConverse.route.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
})
}
const converseModel = Route.model<ModelInput>(
BedrockConverse.route,
{
provider: "amazon-bedrock",
},
{
mapInput: (input) => {
const { credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return {
...rest,
baseURL: baseURL ?? bedrockBaseURL(resolvedRegion),
native: BedrockConverse.nativeCredentials(input.native, credentials),
}
},
},
)
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const model = (modelID: string | ModelID, options: ModelOptions = {}) =>
converseModel({ ...options, id: modelID })
export const provider = configure()
export const model = provider.model
export const provider = Provider.make({
id,
model,
})
+10 -27
View File
@@ -1,6 +1,5 @@
import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import type { RouteModelInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as AnthropicMessages from "../protocols/anthropic-messages"
@@ -8,28 +7,12 @@ export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route]
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export const model = (
id: string | ModelID,
options: Omit<RouteModelInput, "id" | "baseURL"> & { readonly baseURL?: string } = {},
) => AnthropicMessages.model({ ...options, id })
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key"))
}
const configuredRoute = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model = provider.model
export const provider = Provider.make({
id,
model,
})
+44 -71
View File
@@ -1,110 +1,83 @@
import { Auth } from "../route/auth"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import { Route } from "../route/client"
import type { ModelInput } from "../llm"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization")
const routeAuth = Auth.remove("authorization").andThen(Auth.apiKeyHeader("api-key"))
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
export type ModelOptions = AzureURL &
RouteDefaultsInput &
Omit<ModelInput, "id" | "provider" | "route" | "apiKey" | "auth" | "baseURL"> &
ProviderAuthOption<"optional"> & {
readonly apiVersion?: string
readonly queryParams?: Record<string, string>
readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Config = ModelOptions
type AzureModelInput = ModelOptions & Pick<ModelInput, "id">
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
transport: OpenAIResponses.httpTransport.with({ auth: routeAuth }),
})
const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat",
provider: id,
auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
transport: OpenAIChat.httpTransport.with({ auth: routeAuth }),
})
export const routes = [responsesRoute, chatRoute]
const defaults = (input: Config) => {
const {
apiKey: _,
apiVersion: _apiVersion,
resourceName: _resourceName,
useCompletionUrls: _useCompletionUrls,
baseURL: _baseURL,
queryParams: _queryParams,
...rest
} = input
if ("auth" in rest) {
const { auth: _, ...withoutAuth } = rest
return withoutAuth
}
return rest
}
const auth = (input: Config) => {
if ("auth" in input && input.auth) return input.auth
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey")
.orElse(Auth.config("AZURE_OPENAI_API_KEY"))
.pipe(Auth.header("api-key")),
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: {
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
query: {
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
...input.queryParams,
},
},
})
export const configure = (input: Config) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
const mapInput = (input: AzureModelInput) => {
const { apiKey: _, apiVersion, resourceName, useCompletionUrls, ...rest } = input
return {
id,
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
responses,
chat,
configure,
...withOpenAIOptions(input.id, rest),
auth:
"auth" in input && input.auth
? input.auth
: Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey")
.orElse(Auth.config("AZURE_OPENAI_API_KEY"))
.pipe(Auth.header("api-key")),
),
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: rest.baseURL ?? resourceBaseURL(resourceName!),
queryParams: {
...rest.queryParams,
"api-version": apiVersion ?? rest.queryParams?.["api-version"] ?? "v1",
},
}
}
export const provider = {
id,
configure,
const chatModel = Route.model<AzureModelInput>(chatRoute, {}, { mapInput })
const responsesModel = Route.model<AzureModelInput>(responsesRoute, {}, { mapInput })
export const responses = (modelID: string | ModelID, options: ModelOptions) =>
responsesModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID })
export const model = (modelID: string | ModelID, options: ModelOptions) => {
if (options.useCompletionUrls === true) return chat(modelID, options)
return responses(modelID, options)
}
export const provider = Provider.make({
id,
model,
apis: { responses, chat },
})
export const apis = provider.apis
+68 -56
View File
@@ -1,16 +1,19 @@
import type { Config, Redacted } from "effect"
import { type ModelInput } from "../llm"
import { Provider } from "../provider"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import { Auth } from "../route/auth"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { Route } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
export const id = aiGatewayID
export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
type CloudflareSecret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
type CloudflareSecret = string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>>
type GatewayURL = AtLeastOne<{
readonly accountId: string
@@ -20,26 +23,32 @@ type GatewayURL = AtLeastOne<{
}
export type AIGatewayOptions = GatewayURL &
RouteDefaultsInput &
Omit<ModelInput, "id" | "provider" | "route" | "baseURL" | "apiKey" | "auth"> &
ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret
}
type AIGatewayInput = AIGatewayOptions & Pick<ModelInput, "id">
type WorkersAIURL = AtLeastOne<{
readonly accountId: string
readonly baseURL: string
}>
export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
export type WorkersAIOptions = WorkersAIURL &
Omit<ModelInput, "id" | "provider" | "route" | "baseURL" | "apiKey" | "auth"> &
ProviderAuthOption<"optional">
type WorkersAIInput = WorkersAIOptions & Pick<ModelInput, "id">
export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
if (!input.accountId) throw new Error("Cloudflare.aiGateway requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
const aiGatewayAuth = (input: AIGatewayOptions) => {
const aiGatewayAuth = (input: AIGatewayInput) => {
if ("auth" in input && input.auth) return input.auth
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
.orElse(Auth.config("CLOUDFLARE_API_TOKEN"))
@@ -52,11 +61,11 @@ const aiGatewayAuth = (input: AIGatewayOptions) => {
export const workersAIBaseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
if (!input.accountId) throw new Error("Cloudflare.workersAI requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
const workersAIAuth = (input: WorkersAIOptions) => {
const workersAIAuth = (input: WorkersAIInput) => {
return AuthOptions.bearer(input, workersAIAuthEnvVars)
}
@@ -72,56 +81,59 @@ export const workersAIRoute = OpenAICompatibleChat.route.with({
export const routes = [aiGatewayRoute, workersAIRoute]
const aiGatewayDefaults = (options: AIGatewayOptions) => {
const {
accountId: _accountId,
gatewayId: _gatewayId,
apiKey: _apiKey,
gatewayApiKey: _gatewayApiKey,
baseURL: _baseURL,
auth: _auth,
...rest
} = options
return rest
}
const aiGatewayModel = Route.model<AIGatewayInput>(
aiGatewayRoute,
{
provider: id,
},
{
mapInput: (input) => {
const {
accountId: _accountId,
gatewayId: _gatewayId,
apiKey: _apiKey,
gatewayApiKey: _gatewayApiKey,
auth: _auth,
...rest
} = input
return {
...rest,
auth: aiGatewayAuth(input),
baseURL: aiGatewayBaseURL(input),
}
},
},
)
const workersAIDefaults = (options: WorkersAIOptions) => {
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...rest } = options
return rest
}
const workersAIModel = Route.model<WorkersAIInput>(
workersAIRoute,
{
provider: workersAIID,
},
{
mapInput: (input) => {
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, ...rest } = input
return {
...rest,
auth: workersAIAuth(input),
baseURL: workersAIBaseURL(input),
}
},
},
)
const configureAIGateway = (options: AIGatewayOptions) => {
const route = aiGatewayRoute.with({
...aiGatewayDefaults(options),
endpoint: { baseURL: aiGatewayBaseURL(options) },
auth: aiGatewayAuth(options),
})
return {
id: aiGatewayID,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureAIGateway,
}
}
export const aiGateway = (modelID: string | ModelID, options: AIGatewayOptions) =>
aiGatewayModel({ ...options, id: modelID })
const configureWorkersAI = (options: WorkersAIOptions) => {
const route = workersAIRoute.with({
...workersAIDefaults(options),
endpoint: { baseURL: workersAIBaseURL(options) },
auth: workersAIAuth(options),
})
return {
id: workersAIID,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureWorkersAI,
}
}
export const workersAI = (modelID: string | ModelID, options: WorkersAIOptions) =>
workersAIModel({ ...options, id: modelID })
export const CloudflareAIGateway = {
id: aiGatewayID,
configure: configureAIGateway,
}
export const model = aiGateway
export const CloudflareWorkersAI = {
id: workersAIID,
configure: configureWorkersAI,
}
export const provider = Provider.make({
id,
model,
apis: { aiGateway, workersAI },
})
export const apis = provider.apis
+25 -43
View File
@@ -1,5 +1,6 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { Route } from "../route/client"
import type { ModelInput } from "../llm"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
@@ -9,11 +10,10 @@ export const id = ProviderID.make("github-copilot")
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
// supply `baseURL` explicitly.
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type ModelOptions = Omit<ModelInput, "id" | "provider" | "route"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
type CopilotModelInput = ModelOptions & Pick<ModelInput, "id">
export const shouldUseResponsesApi = (modelID: string | ModelID) => {
const model = String(modelID)
@@ -24,43 +24,25 @@ export const shouldUseResponsesApi = (modelID: string | ModelID) => {
export const routes = [OpenAIResponses.route, OpenAIChat.route]
const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id })
const mapInput = (input: CopilotModelInput) => withOpenAIOptions(input.id, input)
const defaults = (options: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, ...rest } = options
return rest
const chatModel = Route.model<CopilotModelInput>(OpenAIChat.route, { provider: id }, { mapInput })
const responsesModel = Route.model<CopilotModelInput>(OpenAIResponses.route, { provider: id }, { mapInput })
export const responses = (modelID: string | ModelID, options: ModelOptions) =>
responsesModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID })
export const model = (modelID: string | ModelID, options: ModelOptions) => {
const create = shouldUseResponsesApi(modelID) ? responsesModel : chatModel
return create({ ...options, id: modelID })
}
const configuredResponsesRoute = (options: ModelOptions) =>
responsesRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
const configuredChatRoute = (options: ModelOptions) =>
chatRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
export const configure = (options: ModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
return {
id,
model: (modelID: string | ModelID) => (shouldUseResponsesApi(modelID) ? responses(modelID) : chat(modelID)),
responses,
chat,
configure,
}
}
export const provider = {
export const provider = Provider.make({
id,
configure,
}
model,
apis: { responses, chat },
})
export const apis = provider.apis
+10 -27
View File
@@ -1,6 +1,5 @@
import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import type { RouteModelInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as Gemini from "../protocols/gemini"
@@ -8,28 +7,12 @@ export const id = ProviderID.make("google")
export const routes = [Gemini.route]
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export const model = (
id: string | ModelID,
options: Omit<RouteModelInput, "id" | "baseURL"> & { readonly baseURL?: string } = {},
) => Gemini.model({ ...options, id })
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key"))
}
const configuredRoute = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model = provider.model
export const provider = Provider.make({
id,
model,
})
-1
View File
@@ -2,7 +2,6 @@ export * as Anthropic from "./anthropic"
export * as AmazonBedrock from "./amazon-bedrock"
export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google"
export * as OpenAI from "./openai"
+38 -42
View File
@@ -1,60 +1,56 @@
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { OpenAICompatibleChatModelInput } from "../protocols/openai-compatible-chat"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
export const id = ProviderID.make("openai-compatible")
type GenericModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
}
export type ModelOptions = Omit<OpenAICompatibleChatModelInput, "id" | "provider"> & {
readonly provider: string
}
export type FamilyModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
}
type GenericModelOptions = Omit<ModelOptions, "provider"> & {
readonly provider?: string
}
export type FamilyModelOptions = Omit<OpenAICompatibleChatModelInput, "id" | "provider" | "baseURL"> & {
readonly baseURL?: string
}
export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => {
const provider = input.provider ?? "openai-compatible"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = OpenAICompatibleChat.route.with({
...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
export const model = (id: string | ModelID, options: ModelOptions) => {
return OpenAICompatibleChat.model({
...options,
id,
provider: ProviderID.make(options.provider),
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
configure,
}
}
const define = (profile: OpenAICompatibleProfile) => {
const configureProfile = (input: FamilyModelOptions = {}) => {
const facade = configure({
...input,
baseURL: input.baseURL ?? profile.baseURL,
provider: profile.provider,
})
return {
id: ProviderID.make(profile.provider),
model: facade.model,
configure: configureProfile,
}
}
return configureProfile()
}
export const profileModel = (
profile: OpenAICompatibleProfile,
id: string | ModelID,
options: FamilyModelOptions = {},
) =>
OpenAICompatibleChat.model({
...options,
id,
provider: profile.provider,
baseURL: options.baseURL ?? profile.baseURL,
})
export const provider = {
const define = (profile: OpenAICompatibleProfile) =>
Provider.make({
id: ProviderID.make(profile.provider),
model: (id: string | ModelID, options: FamilyModelOptions = {}) => profileModel(profile, id, options),
})
export const provider = Provider.make({
id,
configure,
}
model: (id: string | ModelID, options: GenericModelOptions) =>
model(id, { ...options, provider: options.provider ?? "openai-compatible" }),
})
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
+2 -1
View File
@@ -59,9 +59,10 @@ export const withOpenAIOptions = <Options extends { readonly providerOptions?: O
modelID: string,
options: Options,
defaults: { readonly textVerbosity?: boolean } = {},
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
): Options & { readonly id: string; readonly providerOptions?: ProviderOptions } => {
return {
...options,
id: modelID,
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions),
}
}
+25 -35
View File
@@ -1,5 +1,6 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { Route, RouteDefaultsInput } from "../route/client"
import type { RouteModelInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
@@ -14,50 +15,39 @@ export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, Op
// This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization.
export type Config = RouteDefaultsInput &
type OpenAIModelInput<ModelInput> = Omit<ModelInput, "apiKey" | "auth" | "baseURL"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
return rest
export const responses = (id: string | ModelID, options: OpenAIModelInput<Omit<RouteModelInput, "id">> = {}) => {
const { apiKey: _, ...rest } = options
return OpenAIResponses.model(withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true }))
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: { baseURL: input.baseURL, query: input.queryParams },
})
export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
return {
id,
model: responses,
responses,
responsesWebSocket,
chat,
configure,
}
export const responsesWebSocket = (
id: string | ModelID,
options: OpenAIModelInput<Omit<RouteModelInput, "id">> = {},
) => {
const { apiKey: _, ...rest } = options
return OpenAIResponses.webSocketModel(
withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true }),
)
}
export const provider = configure()
export const chat = (id: string | ModelID, options: OpenAIModelInput<Omit<RouteModelInput, "id">> = {}) => {
const { apiKey: _, ...rest } = options
return OpenAIChat.model(withOpenAIOptions(id, { ...rest, auth: auth(options) }))
}
export const provider = Provider.make({
id,
model: responses,
apis: { responses, responsesWebSocket, chat },
})
export const model = provider.model
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
export const apis = provider.apis
+17 -27
View File
@@ -1,9 +1,9 @@
import { Effect, Schema } from "effect"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Route, type RouteModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Provider } from "../provider"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
@@ -24,11 +24,11 @@ export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL" | "providerOptions"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
type ModelInput = ModelOptions & Pick<RouteModelInput, "id">
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
@@ -68,31 +68,21 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({
id: ADAPTER,
provider: profile.provider,
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
})
export const routes = [route]
const configuredRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({
...rest,
endpoint: { baseURL: baseURL ?? profile.baseURL },
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
})
}
const modelRef = Route.model<ModelInput>(route, {
provider: profile.provider,
baseURL: profile.baseURL,
})
export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const model = (id: string | ModelID, options: ModelOptions = {}) => modelRef({ ...options, id })
export const provider = configure()
export const model = provider.model
export const provider = Provider.make({
id,
model,
})
+25 -29
View File
@@ -1,5 +1,7 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { Route } from "../route/client"
import type { RouteModelInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
@@ -7,50 +9,44 @@ import * as OpenAIResponses from "../protocols/openai-responses"
export const id = ProviderID.make("xai")
export type ModelOptions = RouteDefaultsInput &
export type ModelOptions = Omit<RouteModelInput, "id" | "apiKey" | "auth" | "baseURL"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
}
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const responsesModel = Route.model(OpenAIResponses.route, { provider: id })
const chatModel = OpenAICompatibleChat.model
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAIResponses.route.with({
export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => {
const { apiKey: _, ...rest } = options
return responsesModel({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
auth: auth(options),
id: modelID,
baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
})
}
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAICompatibleChat.route.with({
export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => {
const { apiKey: _, ...rest } = options
return chatModel({
...rest,
auth: auth(options),
id: modelID,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
})
}
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
return {
id,
model: responses,
responses,
chat,
configure,
}
}
export const provider = Provider.make({
id,
model: responses,
apis: { responses, chat },
})
export const provider = configure()
export const model = provider.model
export const responses = provider.responses
export const chat = provider.chat
export const apis = provider.apis
+60 -19
View File
@@ -12,7 +12,6 @@ export class MissingCredentialError extends Error {
export type CredentialError = MissingCredentialError | Config.ConfigError
export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
readonly request: LLMRequest
@@ -23,7 +22,7 @@ export interface AuthInput {
}
export interface Credential {
readonly load: Effect.Effect<Redacted.Redacted, CredentialError>
readonly load: Effect.Effect<Redacted.Redacted<string>, CredentialError>
readonly orElse: (that: Credential) => Credential
readonly bearer: () => Auth
readonly header: (name: string) => Auth
@@ -40,7 +39,7 @@ export interface Auth {
export const isAuth = (input: unknown): input is Auth =>
typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function"
const credential = (load: Effect.Effect<Redacted.Redacted, CredentialError>): Credential => {
const credential = (load: Effect.Effect<Redacted.Redacted<string>, CredentialError>): Credential => {
const self: Credential = {
load,
orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))),
@@ -67,13 +66,16 @@ const fromCredential = (source: Credential, render: (secret: string) => Headers.
source.load.pipe(Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret))))),
)
const secretEffect = (secret: string | Redacted.Redacted, source: string) => {
const secretEffect = (secret: string | Redacted.Redacted<string>, source: string) => {
const redacted = typeof secret === "string" ? Redacted.make(secret) : secret
if (Redacted.value(redacted) === "") return Effect.fail(new MissingCredentialError(source))
return Effect.succeed(redacted)
}
const credentialFromSecret = (secret: Secret, source: string) => {
const credentialFromSecret = (
secret: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>>,
source: string,
) => {
if (typeof secret === "string" || Redacted.isRedacted(secret)) return credential(secretEffect(secret, source))
return credential(
Effect.gen(function* () {
@@ -84,14 +86,17 @@ const credentialFromSecret = (secret: Secret, source: string) => {
export const value = (secret: string, source = "value") => credentialFromSecret(secret, source)
export const optional = (secret: Secret | undefined, source = "optional value") =>
export const optional = (
secret: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | undefined,
source = "optional value",
) =>
secret === undefined
? credential(Effect.fail(new MissingCredentialError(source)))
: credentialFromSecret(secret, source)
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
export const effect = (load: Effect.Effect<Redacted.Redacted<string>, CredentialError>) => credential(load)
export const none = auth((input) => Effect.succeed(input.headers))
@@ -104,32 +109,68 @@ export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Header
export const passthrough = none
const credentialInput = (source: Secret | Credential) =>
const fromModelApiKey = (from: (apiKey: string) => Headers.Input) =>
auth(({ request, headers }) => {
const key = request.model.apiKey
if (!key) return Effect.succeed(headers)
return Effect.succeed(Headers.setAll(headers, from(key)))
})
const credentialInput = (
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) =>
typeof source === "string" || Redacted.isRedacted(source) || Config.isConfig(source)
? credentialFromSecret(source, "value")
: source
export function bearer(source: Secret | Credential): Auth
export function bearer(source: Secret | Credential) {
export function bearer(): Auth
export function bearer(
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
): Auth
export function bearer(
source?: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) {
if (source === undefined) return fromModelApiKey((key) => ({ authorization: `Bearer ${key}` }))
return credentialInput(source).bearer()
}
export const apiKey = bearer
export function header(name: string): (source: Secret | Credential) => Auth
export function header(name: string, source: Secret | Credential): Auth
export function header(name: string, source?: Secret | Credential) {
export const apiKeyHeader = (name: string) => fromModelApiKey((key) => ({ [name]: key }))
export function header(
name: string,
): (source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential) => Auth
export function header(
name: string,
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
): Auth
export function header(
name: string,
source?: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) {
if (source === undefined) {
return (next: Secret | Credential) => credentialInput(next).header(name)
return (
next: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) => credentialInput(next).header(name)
}
return credentialInput(source).header(name)
}
export function bearerHeader(name: string): (source: Secret | Credential) => Auth
export function bearerHeader(name: string, source: Secret | Credential): Auth
export function bearerHeader(name: string, source?: Secret | Credential) {
const render = (input: Secret | Credential) =>
fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` }))
export function bearerHeader(
name: string,
): (source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential) => Auth
export function bearerHeader(
name: string,
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
): Auth
export function bearerHeader(
name: string,
source?: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) {
const render = (
input: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) => fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` }))
if (source === undefined) return render
return render(source)
}
+190 -117
View File
@@ -1,28 +1,31 @@
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import * as Option from "effect/Option"
import { Auth, type Auth as AuthDef } from "./auth"
import { Endpoint, type EndpointPatch } from "./endpoint"
import type { Auth as AuthDef } from "./auth"
import type { Endpoint } from "./endpoint"
import { RequestExecutor } from "./executor"
import type { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Service as WebSocketExecutorService } from "./transport/websocket"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import * as ToolRuntime from "../tool-runtime"
import type { Tools } from "../tool"
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID } from "../schema"
import {
GenerationOptions,
HttpOptions,
LLMRequest,
LLMResponse,
Model,
ModelID,
ModelLimits,
ModelRef,
LLMError as LLMErrorClass,
NoRouteReason,
PreparedRequest,
ProviderID,
RouteID,
mergeGenerationOptions,
mergeHttpOptions,
mergeProviderOptions,
@@ -39,13 +42,11 @@ export interface Route<Body, Prepared = unknown> {
readonly id: string
readonly provider?: ProviderID
readonly protocol: ProtocolID
readonly endpoint: Endpoint<Body>
readonly auth: AuthDef
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: (input: RouteMappedModelInput) => Model
readonly model: <Input extends RouteModelInput = RouteModelInput>(input: Input) => ModelRef
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
@@ -60,77 +61,116 @@ export interface Route<Body, Prepared = unknown> {
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute = Route<any, any>
const routeRegistry = new Map<string, AnyRoute>()
// Route lookup is intentionally global: model refs name a route id, and
// importing the provider/protocol/custom-route module registers the runnable
// implementation. Duplicate ids are bugs because model refs cannot disambiguate
// them.
const register = <R extends AnyRoute>(route: R): R => {
const existing = routeRegistry.get(route.id)
if (existing && existing !== route) throw new Error(`Duplicate LLM route id "${route.id}"`)
routeRegistry.set(route.id, route)
return route
}
const registeredRoute = (id: string) => routeRegistry.get(id)
export type HttpOptionsInput = HttpOptions.Input
export type RouteModelInput = Omit<Model.Input, "provider" | "route">
export type RouteRoutedModelInput = Omit<Model.Input, "route">
export interface RouteDefaults {
readonly headers?: Record<string, string>
readonly limits?: ModelLimits
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
}
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
export type ModelRefInput = Omit<
ConstructorParameters<typeof ModelRef>[0],
"id" | "provider" | "route" | "limits" | "generation" | "http" | "auth"
> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly route: string | RouteID
readonly auth?: AuthDef
readonly limits?: ModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
readonly http?: HttpOptionsInput
}
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly id?: string
// `baseURL` is required on `ModelRefInput` (every materialized `ModelRef` has
// a host) but optional at the route-input layers below. The route's `defaults`
// can supply a canonical URL (e.g. OpenAI/Anthropic) so the user's input may
// omit it. Routes without a canonical URL (OpenAI-compatible, GitHub Copilot)
// re-tighten this in their own input type.
export type RouteModelInput = Omit<ModelRefInput, "provider" | "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteModelDefaults = Omit<ModelRefInput, "id" | "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteRoutedModelInput = Omit<ModelRefInput, "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteRoutedModelDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
export type RouteDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
export interface RoutePatch<Body, Prepared> extends RouteDefaults {
readonly id: string
readonly provider?: string | ProviderID
readonly auth?: AuthDef
readonly transport?: Transport<Body, Prepared, unknown>
readonly endpoint?: EndpointPatch<Body>
}
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make({
...mapped,
provider,
route,
})
export interface RouteModelOptions<
Input extends RouteMappedModelInput,
Output extends RouteMappedModelInput = RouteMappedModelInput,
> {
readonly mapInput?: (input: Input) => Output
}
const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaultsInput): RouteDefaults => {
const headers = mergeHeaders(base?.headers, patch.headers)
return {
...base,
...patch,
headers,
limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
base?.http,
httpOptions(patch.http),
headers === undefined ? undefined : new HttpOptions({ headers }),
),
export interface RouteMappedModelOptions<Input, Output extends RouteMappedModelInput = RouteMappedModelInput> {
readonly mapInput: (input: Input) => Output
}
const modelWithDefaults =
<Input>(
route: AnyRoute,
defaults: Partial<Omit<ModelRefInput, "id" | "route">>,
options: { readonly mapInput?: (input: Input) => RouteMappedModelInput },
) =>
(input: Input) => {
const mapped = options.mapInput === undefined ? (input as RouteMappedModelInput) : options.mapInput(input)
const provider = defaults.provider ?? route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL
if (!baseURL)
throw new Error(`Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`)
const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation)
const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions)
const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http))
return modelRef({
...route.defaults,
...defaults,
...mapped,
baseURL,
provider,
route: route.id,
limits: mapped.limits ?? defaults.limits ?? route.defaults.limits,
generation: mergeGenerationOptions(generation, mapped.generation),
providerOptions: mergeProviderOptions(providerOptions, mapped.providerOptions),
http: mergeHttpOptions(http, httpOptions(mapped.http)),
})
}
}
const endpointBaseURL = <Body>(endpoint: Endpoint<Body>) =>
typeof endpoint.baseURL === "string" ? endpoint.baseURL : undefined
const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaults): RouteDefaults => ({
...base,
...patch,
limits: patch.limits ?? base?.limits,
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(httpOptions(base?.http), httpOptions(patch.http)),
})
const mergeHeaders = (...items: ReadonlyArray<Record<string, string> | undefined>) => {
const entries = items.flatMap((item) =>
item === undefined ? [] : Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
if (entries.length === 0) return undefined
return Object.fromEntries(entries)
}
export const modelLimits = ModelLimits.make
export const generationOptions = (input: GenerationOptions.Input | undefined) =>
input === undefined ? undefined : GenerationOptions.make(input)
@@ -140,6 +180,40 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
return HttpOptions.make(input)
}
export const modelRef = (input: ModelRefInput) =>
new ModelRef({
...input,
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: RouteID.make(input.route),
limits: modelLimits(input.limits),
generation: generationOptions(input.generation),
http: httpOptions(input.http),
})
function model<Input extends RouteModelInput = RouteModelInput>(
route: AnyRoute,
defaults: RouteModelDefaults,
options?: RouteModelOptions<Input, RouteModelInput>,
): (input: Input) => ModelRef
function model<Input extends RouteRoutedModelInput = RouteRoutedModelInput>(
route: AnyRoute,
defaults?: RouteRoutedModelDefaults,
options?: RouteModelOptions<Input, RouteRoutedModelInput>,
): (input: Input) => ModelRef
function model<Input, Output extends RouteMappedModelInput = RouteMappedModelInput>(
route: AnyRoute,
defaults: Partial<Omit<ModelRefInput, "id" | "route">>,
options: RouteMappedModelOptions<Input, Output>,
): (input: Input) => ModelRef
function model<Input>(
route: AnyRoute,
defaults: Partial<Omit<ModelRefInput, "id" | "route">> = {},
options: { readonly mapInput?: (input: Input) => RouteMappedModelInput } = {},
) {
return modelWithDefaults(route, defaults, options)
}
export interface Interface {
/**
* Compile a request through protocol body construction, validation, and HTTP
@@ -168,16 +242,22 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const noRoute = (model: ModelRef) =>
new LLMErrorClass({
module: "LLMClient",
method: "resolveRoute",
reason: new NoRouteReason({ route: model.route, provider: model.provider, model: model.id }),
})
const resolveRequestOptions = (request: LLMRequest) =>
LLMRequest.update(request, {
generation:
mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions),
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
generation: mergeGenerationOptions(request.model.generation, request.generation) ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(request.model.providerOptions, request.providerOptions),
http: mergeHttpOptions(request.model.http, request.http),
})
export interface MakeInput<Body, Frame, Event, State> {
/** Route id used in diagnostics and prepared request metadata. */
/** Route id used in registry lookup and error messages. */
readonly id: string
/** Provider identity for route-owned model construction. */
readonly provider?: string | ProviderID
@@ -185,33 +265,27 @@ export interface MakeInput<Body, Frame, Event, State> {
readonly protocol: Protocol<Body, Frame, Event, State>
/** Where the request is sent. */
readonly endpoint: Endpoint<Body>
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
/** Per-request transport auth. Model-level `Auth` overrides this. */
readonly auth?: AuthDef
/** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */
readonly framing: Framing<Frame>
/** Static / per-request headers added before `auth` runs. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
/** Route/request defaults used when compiling requests for this route. */
readonly defaults?: RouteDefaultsInput
/** Model defaults used by the route's `.model(...)` helper. */
readonly defaults?: RouteDefaults
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
/** Route id used in diagnostics and prepared request metadata. */
/** Route id used in registry lookup and error messages. */
readonly id: string
/** Provider identity for route-owned model construction. */
readonly provider?: string | ProviderID
/** Semantic API contract — owns body construction, body schema, and parsing. */
readonly protocol: Protocol<Body, Frame, Event, State>
/** Where the request is sent. */
readonly endpoint: Endpoint<Body>
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
readonly auth?: AuthDef
/** Static / per-request headers added before `auth` runs. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
/** Runnable transport route. */
readonly transport: Transport<Body, Prepared, Frame>
/** Route/request defaults used when compiling requests for this route. */
readonly defaults?: RouteDefaultsInput
/** Provider/model defaults used by the route's `.model(...)` helper. */
readonly defaults?: RouteDefaults
}
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
@@ -224,7 +298,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared> {
const protocol = input.protocol
const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
const decodeEvent = (route: string) => (frame: Frame) =>
decodeEventEffect(frame).pipe(
@@ -237,44 +310,29 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
),
)
type BuiltRouteInput = Omit<MakeTransportInput<Body, Prepared, Frame, Event, State>, "defaults"> & {
readonly defaults?: RouteDefaults
}
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
const build = (routeInput: MakeTransportInput<Body, Prepared, Frame, Event, State>): Route<Body, Prepared> => {
const route: Route<Body, Prepared> = {
id: routeInput.id,
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
protocol: protocol.id,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, auth, transport, endpoint, ...defaults } = patch
const { id, provider, transport, ...defaults } = patch
if (!id || id === routeInput.id) throw new Error(`Route.with(${routeInput.id}) requires a new route id`)
return build({
...routeInput,
id: id ?? routeInput.id,
id,
provider: provider ?? routeInput.provider,
auth: auth ?? routeInput.auth,
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
defaults: mergeRouteDefaults(route.defaults, defaults),
defaults: mergeRouteDefaults(routeInput.defaults, defaults),
})
},
model: (input) => makeRouteModel(route, input),
prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
request,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
}),
model: (input: RouteModelInput): ModelRef => modelWithDefaults<RouteModelInput>(route, {}, {})(input),
prepareTransport: routeInput.transport.prepare,
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
const route = `${request.model.provider}/${request.model.route}`
const events = routeInput.transport
.frames(prepared, request, runtime)
.pipe(
@@ -291,10 +349,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
)
},
} satisfies Route<Body, Prepared>
return route
return register(route)
}
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
return build(input)
}
export function make<Body, Prepared, Frame, Event, State>(
@@ -323,14 +381,18 @@ export function make<Body, Prepared, Frame, Event, State>(
): Route<Body, Prepared> | Route<Body, HttpTransport.HttpPrepared<Frame>> {
if ("transport" in input) return makeFromTransport(input)
const protocol = input.protocol
const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
return makeFromTransport({
id: input.id,
provider: input.provider,
protocol,
endpoint: input.endpoint,
auth: input.auth,
headers: input.headers,
transport: HttpTransport.httpJson({ framing: input.framing }),
transport: HttpTransport.httpJson({
endpoint: input.endpoint,
auth: input.auth,
framing: input.framing,
encodeBody,
headers: input.headers,
}),
defaults: input.defaults,
})
}
@@ -340,7 +402,8 @@ export function make<Body, Prepared, Frame, Event, State>(
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const route = registeredRoute(resolved.model.route)
if (!route) return yield* noRoute(resolved.model)
const body = yield* route.body
.from(resolved)
@@ -432,21 +495,31 @@ export const streamRequest = (request: LLMRequest) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamWith(
streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}),
)
const stream = streamWith(streamRequestWith({ http: yield* RequestExecutor.Service }))
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}),
)
export const Route = { make } as const
export const layerWithWebSocket: Layer.Layer<Service, never, RequestExecutor.Service | WebSocketExecutorService> =
Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamWith(
streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: yield* WebSocketExecutor.Service,
}),
)
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}),
)
export const Route = { make, model } as const
export const LLMClient = {
Service,
layer,
layerWithWebSocket,
prepare,
stream,
generate,
+7 -21
View File
@@ -11,42 +11,28 @@ export type EndpointPart<Body> = string | ((input: EndpointInput<Body>) => strin
/**
* Declarative URL construction for one route.
*
* `Endpoint` carries URL construction for one route. Routes with a canonical
* host put `baseURL` here; provider helpers can override it by configuring the
* route before selecting a model.
* `Endpoint` carries only the path. The host always lives on `model.baseURL`,
* supplied by the provider helper that constructs the model. `render(...)`
* just appends the path (and any `model.queryParams`) to that host.
*
* `path` may be a string or a function of `EndpointInput`, for routes whose
* URL embeds the model id, region, or another body field (e.g. Bedrock,
* Gemini).
*/
export interface Endpoint<Body> {
readonly baseURL?: string
readonly path: EndpointPart<Body>
readonly query?: Record<string, string>
}
export type EndpointPatch<Body> = Partial<Endpoint<Body>>
/** Construct an `Endpoint` from a path string or path function. */
export const path = <Body>(value: EndpointPart<Body>, options: Omit<Endpoint<Body>, "path"> = {}): Endpoint<Body> => ({
...options,
path: value,
})
export const merge = <Body>(base: Endpoint<Body>, patch: EndpointPatch<Body>): Endpoint<Body> => ({
...base,
...patch,
baseURL: patch.baseURL ?? base.baseURL,
path: patch.path ?? base.path,
query: patch.query === undefined ? base.query : { ...base.query, ...patch.query },
})
export const path = <Body>(value: EndpointPart<Body>): Endpoint<Body> => ({ path: value })
const renderPart = <Body>(part: EndpointPart<Body>, input: EndpointInput<Body>) =>
typeof part === "function" ? part(input) : part
export const render = <Body>(endpoint: Endpoint<Body>, input: EndpointInput<Body>) => {
const url = new URL(`${ProviderShared.trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`)
for (const [key, value] of Object.entries(endpoint.query ?? {})) url.searchParams.set(key, value)
const url = new URL(`${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`)
const params = input.request.model.queryParams
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
return url
}
+4 -3
View File
@@ -1,13 +1,14 @@
export { Route, LLMClient } from "./client"
export { Route, LLMClient, modelLimits, modelRef } from "./client"
export type {
Route as RouteShape,
RouteModelDefaults,
RouteModelInput,
RouteRoutedModelDefaults,
RouteRoutedModelInput,
RouteDefaults,
RouteDefaultsInput,
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
ModelRefInput,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
+41 -27
View File
@@ -1,13 +1,20 @@
import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing, type Framing as FramingDef } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import { Auth, type Auth as AuthDef } from "../auth"
import { type Endpoint, render as renderEndpoint } from "../endpoint"
import type { Framing } from "../framing"
import type { Transport } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
export interface JsonRequestInput<Body> {
readonly body: Body
readonly request: LLMRequest
readonly endpoint: Endpoint<Body>
readonly auth: AuthDef
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
}
export interface JsonRequestParts<Body = unknown> {
readonly url: string
@@ -18,7 +25,7 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly framing: FramingDef<Frame>
readonly framing: Framing<Frame>
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -45,21 +52,28 @@ export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
input.request.http?.query,
)
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
const headers = yield* Auth.toEffect(input.auth)({
request: input.request,
method: "POST",
url,
body: body.bodyText,
headers: Headers.fromInput({
...input.headers?.({ request: input.request }),
...input.request.http?.headers,
}),
})
const headers = yield* Auth.toEffect(Auth.isAuth(input.request.model.auth) ? input.request.model.auth : input.auth)(
{
request: input.request,
method: "POST",
url,
body: body.bodyText,
headers: Headers.fromInput({
...(input.headers?.({ request: input.request }) ?? {}),
...input.request.model.headers,
...input.request.http?.headers,
}),
},
)
return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers }
})
export interface HttpJsonInput<_Body, Frame> {
readonly framing: FramingDef<Frame>
export interface HttpJsonInput<Body, Frame> {
readonly endpoint: Endpoint<Body>
readonly auth?: AuthDef
readonly framing: Framing<Frame>
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
}
export type HttpJsonPatch<Body, Frame> = Partial<HttpJsonInput<Body, Frame>>
@@ -71,9 +85,14 @@ export interface HttpJsonTransport<Body, Frame> extends Transport<Body, HttpPrep
export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJsonTransport<Body, Frame> => ({
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
prepare: (body, request) =>
jsonRequestParts({
...prepareInput,
body,
request,
endpoint: input.endpoint,
auth: input.auth ?? Auth.bearer(),
encodeBody: input.encodeBody,
headers: input.headers,
}).pipe(
Effect.map((parts) => ({
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
@@ -90,8 +109,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
`${request.model.provider}/${request.model.route}`,
`Failed to read ${request.model.provider}/${request.model.route} stream`,
ProviderShared.errorText(error),
),
),
@@ -101,8 +120,3 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
),
),
})
export const sseJson = {
id: "http-json/sse",
with: <Body>() => httpJson<Body, string>({ framing: Framing.sse }),
} as const
+1 -12
View File
@@ -1,6 +1,4 @@
import type { Effect, Stream } from "effect"
import type { Endpoint } from "../endpoint"
import type { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema"
@@ -12,7 +10,7 @@ export interface TransportRuntime {
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
readonly prepare: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly frames: (
prepared: Prepared,
request: LLMRequest,
@@ -20,14 +18,5 @@ export interface Transport<Body, Prepared, Frame> {
) => Stream.Stream<Frame, LLMError>
}
export interface TransportPrepareInput<Body> {
readonly body: Body
readonly request: LLMRequest
readonly endpoint: Endpoint<Body>
readonly auth: Auth
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
}
export * as HttpTransport from "./http"
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+14 -13
View File
@@ -1,6 +1,7 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Cause, Context, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth } from "../auth"
import { Auth, type Auth as AuthDef } from "../auth"
import type { Endpoint } from "../endpoint"
import { LLMError, TransportReason, type LLMRequest } from "../../schema"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
@@ -134,8 +135,6 @@ export const open = (input: WebSocketRequest) =>
}),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
export const fromWebSocket = (
ws: globalThis.WebSocket,
input: WebSocketRequest,
@@ -214,8 +213,12 @@ export interface JsonPrepared {
}
export interface JsonInput<Body, Message> {
readonly endpoint: Endpoint<Body>
readonly auth?: AuthDef
readonly encodeBody: (body: Body) => string
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, LLMError>
readonly encodeMessage: (message: Message) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
}
export type JsonPatch<Body, Message> = Partial<JsonInput<Body, Message>>
@@ -227,10 +230,15 @@ export interface JsonTransport<Body, Message> extends Transport<Body, JsonPrepar
export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransport<Body, Message> => ({
id: "websocket-json",
with: (patch) => json({ ...input, ...patch }),
prepare: (prepareInput) =>
prepare: (body, request) =>
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts({
...prepareInput,
body,
request,
endpoint: input.endpoint,
auth: input.auth ?? Auth.bearer(),
encodeBody: input.encodeBody,
headers: input.headers,
})
return {
url: yield* webSocketUrl(parts.url),
@@ -262,14 +270,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
},
})
export const jsonTransport = {
id: "websocket-json",
with: json,
} as const
export const WebSocketExecutor = {
Service,
layer,
open,
fromWebSocket,
messageText,
@@ -277,5 +279,4 @@ export const WebSocketExecutor = {
export const WebSocketTransport = {
json,
jsonTransport,
} as const
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { ModelRef } from "./options"
import { ToolResultValue } from "./messages"
/**
@@ -290,7 +290,7 @@ export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.Prepared
id: Schema.String,
route: RouteID,
protocol: ProtocolID,
model: ModelSchema,
model: ModelRef,
body: Schema.Unknown,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
+12 -42
View File
@@ -1,7 +1,9 @@
import { Schema } from "effect"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const systemPartSchema = Schema.Struct({
type: Schema.Literal("text"),
@@ -39,49 +41,17 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export const ToolResultMediaPart = Schema.Struct({
type: Schema.Literal("media"),
mediaType: Schema.String,
data: Schema.String,
filename: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).annotate({ identifier: "LLM.ToolResult.Media" })
export type ToolResultMediaPart = Schema.Schema.Type<typeof ToolResultMediaPart>
export const ToolResultContentPart = Schema.Union([TextPart, ToolResultMediaPart])
export type ToolResultContentPart = Schema.Schema.Type<typeof ToolResultContentPart>
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value
isRecord(value) && (value.type === "text" || value.type === "json" || value.type === "error") && "value" in value
export const ToolResultValue = Object.assign(
Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolResultContentPart),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
Schema.Struct({
type: Schema.Literals(["json", "text", "error"]),
value: Schema.Unknown,
}).annotate({ identifier: "LLM.ToolResult" }),
{
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
},
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue =>
isToolResultValue(value) ? value : { type, value },
},
)
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
@@ -227,7 +197,7 @@ export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: ModelSchema,
model: ModelRef,
system: Schema.Array(SystemPart),
messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition),
+56 -47
View File
@@ -1,7 +1,8 @@
import { Schema } from "effect"
import { JsonSchema, ModelID, ProviderID } from "./ids"
import type { AnyRoute } from "../route/client"
import { isRecord } from "../utils/record"
import { JsonSchema, ModelID, ProviderID, RouteID } from "./ids"
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
export const mergeJsonRecords = (
...items: ReadonlyArray<Record<string, unknown> | undefined>
@@ -134,59 +135,67 @@ export namespace ModelLimits {
input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
}
export class Model {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
export class ModelRef extends Schema.Class<ModelRef>("LLM.ModelRef")({
id: ModelID,
provider: ProviderID,
route: RouteID,
baseURL: Schema.String,
/** Provider-specific API key convenience. Provider helpers normalize this into `auth`. */
apiKey: Schema.optional(Schema.String),
/** Optional transport auth policy. Opaque because it may contain functions. */
auth: Schema.optional(Schema.Any),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
/**
* Query params appended to the request URL by `Endpoint.baseURL`. Used for
* deployment-level URL-scoped settings such as Azure's `api-version` or any
* provider that requires a per-request key in the URL. Generic concern, so
* lives as a typed first-class field instead of `native`.
*/
queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
limits: ModelLimits,
/** Provider-neutral generation defaults. Request-level values override them. */
generation: Schema.optional(GenerationOptions),
/** Provider-owned typed-at-the-facade options for non-portable knobs. */
providerOptions: Schema.optional(ProviderOptions),
/** Serializable raw HTTP overlays applied to the final outgoing request. */
http: Schema.optional(HttpOptions),
/**
* Provider-specific opaque options. Reach for this only when the value is
* genuinely provider-private and does not fit a typed axis (e.g. Bedrock's
* `aws_credentials` / `aws_region` for SigV4). Anything used by more than
* one route should grow into a typed field instead.
*/
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
constructor(input: Model.ConstructorInput) {
this.id = input.id
this.provider = input.provider
this.route = input.route
}
export namespace ModelRef {
export type Input = ConstructorParameters<typeof ModelRef>[0]
static make(input: Model.Input) {
return new Model({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
})
}
export const input = (model: ModelRef): Input => ({
id: model.id,
provider: model.provider,
route: model.route,
baseURL: model.baseURL,
apiKey: model.apiKey,
auth: model.auth,
headers: model.headers,
queryParams: model.queryParams,
limits: model.limits,
generation: model.generation,
providerOptions: model.providerOptions,
http: model.http,
native: model.native,
})
static input(model: Model): Model.ConstructorInput {
return {
id: model.id,
provider: model.provider,
route: model.route,
}
}
static update(model: Model, patch: Partial<Model.Input>) {
export const update = (model: ModelRef, patch: Partial<Input>) => {
if (Object.keys(patch).length === 0) return model
return Model.make({
...Model.input(model),
return new ModelRef({
...input(model),
...patch,
})
}
}
export namespace Model {
export type ConstructorInput = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
}
export type Input = Omit<ConstructorInput, "id" | "provider"> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
}
}
export type ModelInput = Model.Input
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]),
ttlSeconds: Schema.optional(Schema.Number),
+7 -13
View File
@@ -11,8 +11,7 @@ import {
ToolCallPart,
ToolFailure,
ToolResultPart,
ToolResultValue,
type ToolResultValue as ToolResultValueType,
type ToolResultValue,
Usage,
} from "./schema"
import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool"
@@ -277,10 +276,7 @@ const appendStreamingText = (
state.assistantContent.push({ type, text, providerMetadata })
}
const dispatch = (
tools: Tools,
call: ToolCallPart,
): Effect.Effect<{ result: ToolResultValueType; error?: unknown }> => {
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
const tool = tools[call.name]
if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
if (!tool.execute)
@@ -289,7 +285,7 @@ const dispatch = (
return decodeAndExecute(tool, call).pipe(
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({
result: { type: "error" as const, value: failure.message } satisfies ToolResultValueType,
result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
error: failure.error,
}),
),
@@ -297,7 +293,7 @@ const dispatch = (
)
}
const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolResultValueType, ToolFailure> =>
const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolResultValue, ToolFailure> =>
tool._decode(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })),
@@ -311,12 +307,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
),
),
),
Effect.map(
(encoded): ToolResultValueType => (ToolResultValue.is(encoded) ? encoded : { type: "json", value: encoded }),
),
Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
)
const emitEvents = (call: ToolCallPart, result: ToolResultValueType, error: unknown): ReadonlyArray<LLMEvent> =>
const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
@@ -327,7 +321,7 @@ const emitEvents = (call: ToolCallPart, result: ToolResultValueType, error: unkn
const followUpRequest = (
request: LLMRequest,
state: StepState,
dispatched: ReadonlyArray<readonly [ToolCallPart, ToolResultValueType]>,
dispatched: ReadonlyArray<readonly [ToolCallPart, ToolResultValue]>,
) =>
LLMRequest.update(request, {
messages: [
-3
View File
@@ -1,3 +0,0 @@
/** Plain-record narrowing. Excludes arrays so JSON object checks don't accept tuples as key/value bags. */
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
+51 -38
View File
@@ -1,12 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { Model } from "../src/schema"
import { Route, Endpoint, LLMClient, Protocol, type RouteModelInput, type FramingDef } from "../src/route"
import { ModelRef } from "../src/schema"
import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
const updateModel = (model: Model, patch: Partial<Model.Input>) => Model.update(model, patch)
const updateModel = (model: ModelRef, patch: Partial<ModelRef.Input>) => ModelRef.update(model, patch)
const Json = Schema.fromJsonString(Schema.Unknown)
const encodeJson = Schema.encodeSync(Json)
@@ -38,6 +38,17 @@ const fakeFraming: FramingDef<FakeEvent> = {
).pipe(Stream.flatMap(Stream.fromIterable)),
}
const request = LLM.request({
id: "req_1",
model: LLM.model({
id: "fake-model",
provider: "fake-provider",
route: "fake",
baseURL: "https://fake.local",
}),
prompt: "hello",
})
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish"
? { type: "finish", reason: event.reason }
@@ -73,7 +84,6 @@ const fake = Route.make({
endpoint: Endpoint.path("/chat"),
framing: fakeFraming,
})
const configuredFake = fake.with({ endpoint: { baseURL: "https://fake.local" } })
const gemini = Route.make({
id: "gemini-fake",
@@ -81,17 +91,6 @@ const gemini = Route.make({
endpoint: Endpoint.path("/chat"),
framing: fakeFraming,
})
const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local" } })
const request = LLM.request({
id: "req_1",
model: Model.make({
id: "fake-model",
provider: "fake-provider",
route: configuredFake,
}),
prompt: "hello",
})
const echoLayer = dynamicResponse(({ text, respond }) =>
Effect.succeed(
@@ -118,47 +117,61 @@ describe("llm route", () => {
}),
)
it.effect("selects routes by model route value", () =>
it.effect("selects routes by request route", () =>
Effect.gen(function* () {
const llm = yield* LLMClient.Service
const prepared = yield* llm.prepare(
LLM.updateRequest(request, { model: updateModel(request.model, { route: configuredGemini }) }),
LLM.updateRequest(request, { model: updateModel(request.model, { route: "gemini-fake" }) }),
)
expect(prepared.route).toBe("gemini-fake")
}),
)
it.effect("builds models from configured routes", () =>
it.effect("maps model input before building refs", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
const mapped = Route.model<RouteModelInput & { readonly region?: string }>(
fake,
{ provider: "fake-provider", baseURL: "https://fake.local" },
{
mapInput: (input) => {
const { region, ...rest } = input
return { ...rest, native: { region } }
},
},
)
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
expect(mapped({ id: "fake-model", region: "us-east-1" }).native).toEqual({ region: "us-east-1" })
}),
)
it.effect("does not register duplicate route ids globally", () =>
it.effect("rejects duplicate route ids", () =>
Effect.gen(function* () {
const duplicate = Route.make({
id: "fake",
protocol: Protocol.make({
...fakeProtocol,
body: {
...fakeProtocol.body,
from: () => Effect.succeed({ body: "late-default" }),
},
expect(() =>
Route.make({
id: "fake",
protocol: Protocol.make({
...fakeProtocol,
body: {
...fakeProtocol.body,
from: () => Effect.succeed({ body: "late-default" }),
},
}),
endpoint: Endpoint.path("/chat"),
framing: fakeFraming,
}),
endpoint: Endpoint.path("/chat", { baseURL: "https://fake.local" }),
framing: fakeFraming,
})
).toThrow('Duplicate LLM route id "fake"')
}),
)
const prepared = yield* (yield* LLMClient.Service).prepare(
LLM.updateRequest(request, { model: updateModel(request.model, { route: duplicate }) }),
)
it.effect("rejects missing route", () =>
Effect.gen(function* () {
const llm = yield* LLMClient.Service
const error = yield* llm
.prepare(LLM.updateRequest(request, { model: updateModel(request.model, { route: "missing" }) }))
.pipe(Effect.flip)
expect(prepared.body).toEqual({ body: "late-default" })
expect(error.message).toContain("No LLM route")
}),
)
})
+28 -96
View File
@@ -2,17 +2,8 @@ import { Config } from "effect"
import type { Auth } from "../src/route/auth"
import type { ModelFactory } from "../src/route/auth-options"
import { Auth as RuntimeAuth } from "../src/route/auth"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
import * as Anthropic from "../src/providers/anthropic"
import * as Azure from "../src/providers/azure"
import * as Cloudflare from "../src/providers/cloudflare"
import * as GitHubCopilot from "../src/providers/github-copilot"
import * as Google from "../src/providers/google"
import * as OpenAI from "../src/providers/openai"
import * as OpenAICompatible from "../src/providers/openai-compatible"
import * as OpenRouter from "../src/providers/openrouter"
import * as XAI from "../src/providers/xai"
type BaseOptions = {
readonly baseURL?: string
@@ -28,20 +19,6 @@ declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
const configApiKey = Config.redacted("OPENAI_API_KEY")
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
// @ts-expect-error route model selection does not configure endpoints.
OpenAIChat.route.model({ id: "gpt-4.1-mini", baseURL: "https://gateway.example.com/v1" })
// @ts-expect-error route model selection does not configure query params.
OpenAIChat.route.model({ id: "gpt-4.1-mini", queryParams: { debug: "1" } })
// @ts-expect-error route model selection does not configure auth.
OpenAIChat.route.model({ id: "gpt-4.1-mini", auth })
// @ts-expect-error route model selection does not configure api keys.
OpenAIChat.route.model({ id: "gpt-4.1-mini", apiKey: "sk-test" })
optionalAuthModel("gpt-4.1-mini")
optionalAuthModel("gpt-4.1-mini", {})
optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test" })
@@ -68,101 +45,56 @@ requiredAuthModel("custom-model", {})
requiredAuthModel("custom-model", { apiKey: "key", auth })
OpenAI.responses("gpt-4.1-mini")
OpenAI.configure({}).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini")
OpenAI.configure({
OpenAI.responses("gpt-4.1-mini", {})
OpenAI.responses("gpt-4.1-mini", { apiKey: "sk-test" })
OpenAI.responses("gpt-4.1-mini", { apiKey: configApiKey })
OpenAI.responses("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.responses("gpt-4.1-mini", {
auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }),
baseURL: "https://gateway.example.com/v1",
}).responses("gpt-4.1-mini")
OpenAI.configure({
})
OpenAI.responses("gpt-4.1-mini", {
generation: { maxTokens: 100 },
providerOptions: { openai: { store: false } },
}).responses("gpt-4.1-mini")
// @ts-expect-error OpenAI model selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
})
// @ts-expect-error apiKey only accepts string, Redacted<string>, or Config<string | Redacted<string>>.
OpenAI.configure({ apiKey: 123 })
OpenAI.responses("gpt-4.1-mini", { apiKey: 123 })
// @ts-expect-error provider helpers reject unknown top-level options.
OpenAI.configure({ bogus: true })
OpenAI.responses("gpt-4.1-mini", { bogus: true })
// @ts-expect-error common generation options remain typed.
OpenAI.configure({ generation: { maxTokens: "many" } })
OpenAI.responses("gpt-4.1-mini", { generation: { maxTokens: "many" } })
// @ts-expect-error provider-native options remain typed.
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
OpenAI.responses("gpt-4.1-mini", { providerOptions: { openai: { store: "false" } } })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.responses("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
// @ts-expect-error OpenAI chat selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test" })
OpenAI.chat("gpt-4.1-mini", { apiKey: configApiKey })
OpenAI.chat("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") })
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
Azure.configure()
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
// @ts-expect-error Azure model selectors only accept deployment ids.
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
Azure.responses("deployment")
Azure.responses("deployment", { apiKey: "azure-key", resourceName: "resource" })
Azure.responses("deployment", { apiKey: configApiKey, resourceName: "resource" })
Azure.responses("deployment", { auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" })
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
Azure.responses("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
// @ts-expect-error Azure chat model selectors only accept deployment ids.
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
Azure.chat("deployment")
Azure.chat("deployment", { apiKey: "azure-key", resourceName: "resource" })
Azure.chat("deployment", { apiKey: configApiKey, resourceName: "resource" })
Azure.chat("deployment", { auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" })
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
// @ts-expect-error Google model selectors only accept model ids.
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude")
// @ts-expect-error Bedrock model selectors only accept model ids.
AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude", {})
OpenRouter.configure({ apiKey: "openrouter-key" }).model("openai/gpt-4o-mini")
// @ts-expect-error OpenRouter model selectors only accept model ids.
OpenRouter.configure({ apiKey: "openrouter-key" }).model("openai/gpt-4o-mini", {})
XAI.configure({ apiKey: "xai-key" }).responses("grok-4")
XAI.configure({ apiKey: "xai-key" }).chat("grok-4")
// @ts-expect-error xAI Responses selectors only accept model ids.
XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {})
// @ts-expect-error xAI Chat selectors only accept model ids.
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1")
// @ts-expect-error GitHub Copilot model selectors only accept model ids.
GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {})
Azure.chat("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
+1 -3
View File
@@ -3,13 +3,11 @@ import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { LLM } from "../src"
import { Auth } from "../src/route/auth"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { Model } from "../src/schema"
import { it } from "./lib/effect"
const request = LLM.request({
id: "req_auth",
model: Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "fake", baseURL: "https://fake.local" }),
prompt: "hello",
})
+20 -16
View File
@@ -1,32 +1,36 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src"
import { Auth, LLMClient } from "../src/route"
import { AmazonBedrock } from "../src/providers"
import { LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as BedrockConverse from "../src/protocols/bedrock-converse"
import * as Gemini from "../src/protocols/gemini"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { applyCachePolicy } from "../src/cache-policy"
import { it } from "./lib/effect"
const anthropicModel = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" })
const anthropicModel = AnthropicMessages.model({
id: "claude-sonnet-4-5",
baseURL: "https://api.anthropic.test/v1/",
headers: { "x-api-key": "test" },
})
const bedrockModel = AmazonBedrock.configure({
const bedrockModel = BedrockConverse.model({
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
}).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
})
const openaiModel = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const openaiModel = OpenAIChat.model({
id: "gpt-4o-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const geminiModel = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-2.5-flash" })
const geminiModel = Gemini.model({
id: "gemini-2.5-flash",
baseURL: "https://generativelanguage.test/v1beta/",
headers: { "x-goog-api-key": "test" },
})
describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
+14 -18
View File
@@ -1,40 +1,37 @@
import { describe, expect, test } from "bun:test"
import { LLM } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { Endpoint } from "../src/route"
import { Model } from "../src/schema"
const request = () =>
const request = (input: { readonly baseURL: string; readonly queryParams?: Record<string, string> }) =>
LLM.request({
model: Model.make({
model: LLM.model({
id: "model-1",
provider: "test",
route: OpenAIChat.route,
route: "test-route",
baseURL: input.baseURL,
queryParams: input.queryParams,
}),
prompt: "hello",
})
describe("Endpoint", () => {
test("appends a static path to the model's baseURL", () => {
const url = Endpoint.render(Endpoint.path("/chat", { baseURL: "https://api.example.test/v1/" }), {
request: request(),
const url = Endpoint.render(Endpoint.path("/chat"), {
request: request({ baseURL: "https://api.example.test/v1/" }),
body: {},
})
expect(url.toString()).toBe("https://api.example.test/v1/chat")
})
test("endpoint query params are appended to the rendered URL", () => {
const url = Endpoint.render(
Endpoint.path("/chat?alt=sse", {
test("model query params are appended to the rendered URL", () => {
const url = Endpoint.render(Endpoint.path("/chat?alt=sse"), {
request: request({
baseURL: "https://custom.example.test/root/",
query: { "api-version": "2026-01-01", alt: "json" },
queryParams: { "api-version": "2026-01-01", alt: "json" },
}),
{
request: request(),
body: {},
},
)
body: {},
})
expect(url.toString()).toBe("https://custom.example.test/root/chat?alt=json&api-version=2026-01-01")
})
@@ -43,10 +40,9 @@ describe("Endpoint", () => {
const url = Endpoint.render(
Endpoint.path<{ readonly modelId: string }>(
({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
{ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" },
),
{
request: request(),
request: request({ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" }),
body: { modelId: "us.amazon.nova-micro-v1:0" },
},
)
+4 -6
View File
@@ -106,8 +106,8 @@ describe("RequestExecutor", () => {
expect(errorHttp(error)?.body).toBe("rate limited")
}).pipe(
Effect.provide(
responsesLayer(
Array.from(
responsesLayer([
...Array.from(
{ length: 3 },
() =>
new Response("rate limited", {
@@ -115,7 +115,7 @@ describe("RequestExecutor", () => {
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
),
),
]),
),
),
)
@@ -388,9 +388,7 @@ describe("RequestExecutor", () => {
it.effect("does not retry after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const model = OpenAIChat.model({ id: "gpt-4o-mini", baseURL: "https://api.openai.test/v1" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
+16 -22
View File
@@ -2,14 +2,7 @@ import { describe, expect, test } from "bun:test"
import { LLM, LLMClient, Provider } from "@opencode-ai/llm"
import { Route, Protocol } from "@opencode-ai/llm/route"
import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider"
import {
CloudflareAIGateway,
CloudflareWorkersAI,
OpenAI,
OpenAICompatible,
OpenRouter,
XAI,
} from "@opencode-ai/llm/providers"
import { Cloudflare, OpenAI, OpenAICompatible, OpenRouter, XAI } from "@opencode-ai/llm/providers"
import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot"
import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols"
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
@@ -31,25 +24,26 @@ describe("public exports", () => {
test("provider barrels expose user-facing facades", () => {
expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.model).toBe(OpenAI.model)
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
expect(OpenAI.apis.responses).toBe(OpenAI.responses)
expect(OpenAI.apis.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAICompatible.deepseek.model).toBeFunction()
expect(CloudflareAIGateway.configure).toBeFunction()
expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction()
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(Cloudflare.model).toBeFunction()
expect(Cloudflare.provider.model).toBe(Cloudflare.model)
expect(Cloudflare.aiGateway).toBeFunction()
expect(Cloudflare.workersAI).toBeFunction()
expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
).toBeFunction()
expect(XAI.apis.responses).toBe(XAI.responses)
expect(XAI.apis.chat).toBe(XAI.chat)
expect(XAI.responses("grok-4.3", { apiKey: "fixture" })).toMatchObject({
route: "openai-responses",
})
expect(XAI.chat("grok-4.3", { apiKey: "fixture" })).toMatchObject({
route: "openai-compatible-chat",
})
expect(GitHubCopilot.model).toBeFunction()
})
test("protocol barrels expose supported low-level routes", () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { Auth } from "../src/route"
import { Tool, toDefinitions } from "../src/tool"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
@@ -18,9 +17,11 @@ type OpenAIChatBody = {
}>
}
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const model = OpenAIChat.model({
id: "gpt-4o-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const Json = Schema.fromJsonString(Schema.Unknown)
const decodeJson = Schema.decodeUnknownSync(Json)
+4 -6
View File
@@ -1,9 +1,8 @@
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import { LLMClient, RequestExecutor } from "../../src/route"
import type { Service as LLMClientService } from "../../src/route/client"
import type { Service as RequestExecutorService } from "../../src/route/executor"
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket"
export type HandlerInput = {
readonly request: HttpClientRequest.HttpClientRequest
@@ -32,13 +31,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
),
)
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export type RuntimeEnv = RequestExecutorService | LLMClientService
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
return Layer.mergeAll(deps, llmClientLayer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
}
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
+19 -32
View File
@@ -1,23 +1,18 @@
import { describe, expect, test } from "bun:test"
import { LLM, LLMResponse } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { LLMRequest, Message, Model, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route
import { LLMRequest, Message, ModelRef, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema"
describe("llm constructors", () => {
test("builds canonical schema classes from ergonomic input", () => {
const request = LLM.request({
id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
system: "You are concise.",
prompt: "Say hello.",
})
expect(request).toBeInstanceOf(LLMRequest)
expect(request.model).toBeInstanceOf(Model)
expect(request.model).toBeInstanceOf(ModelRef)
expect(request.messages[0]).toBeInstanceOf(Message)
expect(request.system).toEqual([{ type: "text", text: "You are concise." }])
expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }])
@@ -28,7 +23,7 @@ describe("llm constructors", () => {
test("updates requests without spreading schema class instances", () => {
const base = LLM.request({
id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
prompt: "Say hello.",
})
const updated = LLM.updateRequest(base, {
@@ -43,16 +38,16 @@ describe("llm constructors", () => {
expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"])
})
test("keeps request options separate from route defaults", () => {
test("keeps request options separate from model defaults", () => {
const request = LLM.request({
model: Model.make({
model: LLM.model({
id: "fake-model",
provider: "fake",
route: chatRoute.with({
generation: { maxTokens: 100, temperature: 1 },
providerOptions: { openai: { store: false, metadata: { model: true } } },
http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
}),
route: "openai-chat",
baseURL: "https://fake.local",
generation: { maxTokens: 100, temperature: 1 },
providerOptions: { openai: { store: false, metadata: { model: true } } },
http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
}),
prompt: "Say hello.",
generation: { temperature: 0 },
@@ -72,7 +67,7 @@ describe("llm constructors", () => {
test("updates canonical requests from the request datatype", () => {
const base = LLM.request({
id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
prompt: "Say hello.",
})
const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] })
@@ -85,18 +80,14 @@ describe("llm constructors", () => {
})
test("updates canonical models from the model datatype", () => {
const base = Model.make({
id: "fake-model",
provider: "fake",
route: chatRoute,
})
const updated = Model.update(base, { route: responsesRoute })
const base = LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" })
const updated = ModelRef.update(base, { route: "openai-responses" })
expect(updated).toBeInstanceOf(Model)
expect(updated).toBeInstanceOf(ModelRef)
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(String(Model.input(updated).provider)).toBe("fake")
expect(Model.update(updated, {})).toBe(updated)
expect(updated.route).toBe("openai-responses")
expect(String(ModelRef.input(updated).provider)).toBe("fake")
expect(ModelRef.update(updated, {})).toBe(updated)
})
test("builds tool choices from names and tools", () => {
@@ -114,11 +105,7 @@ describe("llm constructors", () => {
expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
expect(
LLM.request({
model: Model.make({
id: "fake-model",
provider: "fake",
route: chatRoute,
}),
model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }),
prompt: "Use tools if needed.",
toolChoice: "required",
}).toolChoice,
+4 -6
View File
@@ -1,9 +1,9 @@
import { Provider } from "../src/provider"
import { ProviderID, type Model } from "../src/schema"
import { ProviderID, type ModelRef } from "../src/schema"
declare const model: (id: string) => Model
declare const requiredModel: (id: string, options: { readonly baseURL: string }) => Model
declare const chat: (id: string, options: { readonly apiKey: string }) => Model
declare const model: (id: string) => ModelRef
declare const requiredModel: (id: string, options: { readonly baseURL: string }) => ModelRef
declare const chat: (id: string, options: { readonly apiKey: string }) => ModelRef
Provider.make({
id: ProviderID.make("example"),
@@ -22,8 +22,6 @@ const requiredProvider = Provider.make({
model: requiredModel,
})
// Provider.make is advanced structural typing coverage; built-in providers use
// configure(...).model(id) facades instead of second-argument selectors.
requiredProvider.model("custom", { baseURL: "https://example.com/v1" })
// @ts-expect-error Provider.make preserves required model options.
@@ -3,13 +3,14 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = Anthropic.configure({
const model = AnthropicMessages.model({
id: "claude-haiku-4-5-20251001",
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
}).model("claude-haiku-4-5-20251001")
})
// Two identical generations in a row. The first call writes the prefix into
// Anthropic's cache; the second should report a cache read against the same
@@ -3,13 +3,14 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { weatherToolName } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = Anthropic.configure({
const model = AnthropicMessages.model({
id: "claude-haiku-4-5-20251001",
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
}).model("claude-haiku-4-5-20251001")
})
const malformedToolOrderRequest = LLM.request({
id: "recorded_anthropic_malformed_tool_order",
@@ -1,15 +1,17 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
const model = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" })
const model = AnthropicMessages.model({
id: "claude-sonnet-4-5",
baseURL: "https://api.anthropic.test/v1/",
headers: { "x-api-key": "test" },
})
const request = LLM.request({
id: "req_1",
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src"
import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
@@ -12,14 +12,15 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second
// call wouldn't deterministically prove cache mapping works. Override with
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
const model = AmazonBedrock.configure({
const model = BedrockConverse.model({
id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0",
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
})
const cacheRequest = LLM.request({
id: "recorded_bedrock_cache",
@@ -4,7 +4,6 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
@@ -53,10 +52,11 @@ const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>)
const fixedBytes = (bytes: Uint8Array) =>
fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
const model = AmazonBedrock.configure({
const model = BedrockConverse.model({
id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
})
const baseRequest = LLM.request({
id: "req_1",
@@ -156,55 +156,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_tool_image",
model,
messages: [
Message.user("Capture the screen."),
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]),
Message.tool({
id: "tool_1",
name: "screenshot",
result: {
type: "content",
value: [
{ type: "text", text: "Screenshot captured." },
{ type: "media", mediaType: "image/png", data: "AAAA" },
],
},
}),
],
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
messages: [
{ role: "user", content: [{ text: "Capture the screen." }] },
{
role: "assistant",
content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_1",
content: [{ text: "Screenshot captured." }, { image: { format: "png", source: { bytes: "AAAA" } } }],
status: "success",
},
},
],
},
],
})
}),
)
it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -298,32 +249,39 @@ describe("Bedrock Converse route", () => {
it.effect("rejects requests with no auth path", () =>
Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({
const unsignedModel = BedrockConverse.model({
id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
baseURL: "https://bedrock-runtime.test",
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
})
const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.flip,
)
expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
expect(error.message).toContain("Bedrock Converse requires either model.apiKey")
}),
)
it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
Effect.gen(function* () {
const signed = AmazonBedrock.configure({
const signed = BedrockConverse.model({
id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
baseURL: "https://bedrock-runtime.test",
credentials: {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
})
const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
// The prepare phase doesn't sign — toHttp does. We assert the credential
// is plumbed onto the model native field for the signer to find.
expect(prepared.model.native).toMatchObject({
aws_credentials: { region: "us-east-1", accessKeyId: "AKIAIOSFODNN7EXAMPLE" },
aws_region: "us-east-1",
})
}),
)
@@ -573,17 +531,18 @@ describe("Bedrock Converse route", () => {
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
const recordedModel = () =>
AmazonBedrock.configure({
BedrockConverse.model({
// Most newer Anthropic models on Bedrock require a cross-region inference
// profile (`us.` prefix). Nova does not require an Anthropic use-case form
// and is on-demand-throughput accessible by default for most accounts.
id: process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0",
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
}).model(process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0")
})
const recorded = recordedTests({
prefix: "bedrock-converse",
@@ -639,6 +598,7 @@ describe("Bedrock Converse recorded", () => {
recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () =>
Effect.gen(function* () {
const llm = yield* LLMClient.Service
expectWeatherToolLoop(
yield* runWeatherToolLoop(
weatherToolLoopRequest({
+22 -22
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import * as Cloudflare from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
@@ -21,18 +21,18 @@ const deltaChunk = (delta: object, finishReason: string | null = null) => ({
describe("Cloudflare", () => {
it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () =>
Effect.gen(function* () {
const model = CloudflareAIGateway.configure({
const model = Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.3-70b-instruct", {
accountId: "test-account",
gatewayId: "test-gateway",
apiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct")
})
expect(model).toMatchObject({
id: "workers-ai/@cf/meta/llama-3.3-70b-instruct",
provider: "cloudflare-ai-gateway",
route: { id: "cloudflare-ai-gateway" },
route: "cloudflare-ai-gateway",
baseURL: "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat",
})
expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
@@ -49,11 +49,11 @@ describe("Cloudflare", () => {
Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: CloudflareAIGateway.configure({
model: Cloudflare.aiGateway("openai/gpt-4o-mini", {
accountId: "test-account",
gatewayId: "test-gateway",
apiKey: "test-token",
}).model("openai/gpt-4o-mini"),
}),
prompt: "Say hello.",
}),
).pipe(
@@ -86,11 +86,11 @@ describe("Cloudflare", () => {
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
CloudflareAIGateway.configure({
Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.3-70b-instruct", {
accountId: "test-account",
gatewayId: "",
gatewayApiKey: "test-token",
}).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
}).baseURL,
).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
}),
)
@@ -99,11 +99,11 @@ describe("Cloudflare", () => {
Effect.gen(function* () {
yield* LLM.generate(
LLM.request({
model: CloudflareAIGateway.configure({
model: Cloudflare.aiGateway("openai/gpt-4o-mini", {
accountId: "test-account",
gatewayApiKey: "gateway-token",
apiKey: "provider-token",
}).model("openai/gpt-4o-mini"),
}),
prompt: "Say hello.",
}),
).pipe(
@@ -129,31 +129,31 @@ describe("Cloudflare", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: CloudflareAIGateway.configure({
model: Cloudflare.aiGateway("openai/gpt-4o-mini", {
baseURL: "https://gateway.proxy.test/v1/custom/compat",
apiKey: "test-token",
}).model("openai/gpt-4o-mini"),
}),
prompt: "Say hello.",
}),
)
expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat")
expect(prepared.model.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat")
}),
)
it.effect("prepares direct Workers AI models through the OpenAI-compatible Chat protocol", () =>
Effect.gen(function* () {
const model = CloudflareWorkersAI.configure({
const model = Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", {
accountId: "test-account",
apiKey: "test-token",
}).model("@cf/meta/llama-3.1-8b-instruct")
})
expect(model).toMatchObject({
id: "@cf/meta/llama-3.1-8b-instruct",
provider: "cloudflare-workers-ai",
route: { id: "cloudflare-workers-ai" },
route: "cloudflare-workers-ai",
baseURL: "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1",
})
expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
@@ -170,10 +170,10 @@ describe("Cloudflare", () => {
Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: CloudflareWorkersAI.configure({
model: Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", {
accountId: "test-account",
apiKey: "test-token",
}).model("@cf/meta/llama-3.1-8b-instruct"),
}),
prompt: "Say hello.",
}),
).pipe(
@@ -205,9 +205,9 @@ describe("Cloudflare", () => {
Effect.gen(function* () {
yield* LLM.generate(
LLM.request({
model: CloudflareWorkersAI.configure({
model: Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", {
accountId: "test-account",
}).model("@cf/meta/llama-3.1-8b-instruct"),
}),
prompt: "Say hello.",
}),
).pipe(
@@ -2,13 +2,14 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as Google from "../../src/providers/google"
import * as Gemini from "../../src/protocols/gemini"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = Google.configure({
const model = Gemini.model({
id: "gemini-2.5-flash",
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture",
}).model("gemini-2.5-flash")
})
// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The
// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent`
+6 -7
View File
@@ -1,18 +1,17 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents, sseRaw } from "../lib/sse"
const model = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-2.5-flash" })
const model = Gemini.model({
id: "gemini-2.5-flash",
baseURL: "https://generativelanguage.test/v1beta/",
headers: { "x-goog-api-key": "test" },
})
const request = LLM.request({
id: "req_1",
@@ -1,30 +1,32 @@
import { Redactor } from "@opencode-ai/http-recorder"
import * as Anthropic from "../../src/providers/anthropic"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import * as Google from "../../src/providers/google"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import * as Gemini from "../../src/protocols/gemini"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import * as Cloudflare from "../../src/providers/cloudflare"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import * as XAI from "../../src/providers/xai"
import { describeRecordedGoldenScenarios } from "../recorded-golden"
const openAI = OpenAI.configure({
const openAIChat = OpenAIChat.model({ id: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
const openAIResponses = OpenAIResponses.model({ id: "gpt-5.5", apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
const openAIResponsesWebSocket = OpenAI.responsesWebSocket("gpt-4.1-mini", {
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
})
const openAIChat = openAI.chat("gpt-4o-mini")
const openAIResponses = openAI.responses("gpt-5.5")
const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini")
const anthropic = Anthropic.configure({
const anthropicHaiku = AnthropicMessages.model({
id: "claude-haiku-4-5-20251001",
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
})
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
const anthropicOpus = anthropic.model("claude-opus-4-7")
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
const gemini = google.model("gemini-2.5-flash")
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
const xaiBasic = xai.model("grok-3-mini")
const xaiFlagship = xai.model("grok-4.3")
const cloudflareAIGateway = CloudflareAIGateway.configure({
const anthropicOpus = AnthropicMessages.model({
id: "claude-opus-4-7",
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
})
const gemini = Gemini.model({ id: "gemini-2.5-flash", apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
const xaiBasic = XAI.model("grok-3-mini", { apiKey: process.env.XAI_API_KEY ?? "fixture" })
const xaiFlagship = XAI.model("grok-4.3", { apiKey: process.env.XAI_API_KEY ?? "fixture" })
const cloudflareAIGatewayWorkers = Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.1-8b-instruct", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account",
gatewayId:
process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID
@@ -32,31 +34,32 @@ const cloudflareAIGateway = CloudflareAIGateway.configure({
: undefined,
gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture",
})
const cloudflareWorkers = CloudflareWorkersAI.configure({
const cloudflareAIGatewayWorkersTools = Cloudflare.aiGateway("workers-ai/@cf/openai/gpt-oss-20b", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account",
gatewayId:
process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID
? process.env.CLOUDFLARE_GATEWAY_ID
: undefined,
gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture",
})
const cloudflareWorkersAI = Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account",
apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture",
})
const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
const deepseek = OpenAICompatible.deepseek
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
.model("deepseek-chat")
const together = OpenAICompatible.togetherai
.configure({
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
})
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const groq = OpenAICompatible.groq
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
.model("llama-3.3-70b-versatile")
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
const openrouter = openRouter.model("openai/gpt-4o-mini")
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
const openrouterOpus = OpenRouter.configure({
const cloudflareWorkersAITools = Cloudflare.workersAI("@cf/openai/gpt-oss-20b", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account",
apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture",
})
const deepseek = OpenAICompatible.deepseek.model("deepseek-chat", { apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
const together = OpenAICompatible.togetherai.model("meta-llama/Llama-3.3-70B-Instruct-Turbo", {
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
})
const groq = OpenAICompatible.groq.model("llama-3.3-70b-versatile", { apiKey: process.env.GROQ_API_KEY ?? "fixture" })
const openrouter = OpenRouter.model("openai/gpt-4o-mini", { apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
const openrouterGpt55 = OpenRouter.model("openai/gpt-5.5", { apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
const openrouterOpus = OpenRouter.model("anthropic/claude-opus-4.7", {
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
}).model("anthropic/claude-opus-4.7")
})
const redactCloudflareURL = (url: string) =>
url
@@ -117,7 +120,7 @@ describeRecordedGoldenScenarios([
prefix: "gemini",
model: gemini,
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }],
scenarios: [{ id: "text", maxTokens: 80 }, "tool-call"],
},
{
name: "xAI Grok 3 Mini",
+12 -15
View File
@@ -1,11 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { Auth, LLMClient } from "../../src/route"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
@@ -15,9 +15,11 @@ const TargetJson = Schema.fromJsonString(Schema.Unknown)
const encodeJson = Schema.encodeSync(TargetJson)
const decodeJson = Schema.decodeUnknownSync(TargetJson)
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const model = OpenAIChat.model({
id: "gpt-4o-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const request = LLM.request({
id: "req_1",
@@ -54,7 +56,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
model: OpenAI.chat("gpt-4o-mini", { baseURL: "https://api.openai.test/v1/" }),
prompt: "think",
providerOptions: { openai: { reasoningEffort: "low" } },
}),
@@ -67,9 +69,7 @@ describe("OpenAI Chat route", () => {
it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate(
LLM.updateRequest(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}),
LLM.updateRequest(request, { model: OpenAIChat.model({ ...model, queryParams: { "api-version": "v1" } }) }),
).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -88,18 +88,17 @@ describe("OpenAI Chat route", () => {
it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
LLMClient.generate(
LLM.updateRequest(request, {
model: Azure.configure({
model: Azure.chat("gpt-4o-mini", {
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).chat("gpt-4o-mini"),
}),
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/chat/completions?api-version=v1")
expect(web.headers.get("api-key")).toBe("azure-key")
expect(web.headers.get("authorization")).toBeNull()
return input.respond(sseEvents(deltaChunk({}, "stop")), {
@@ -114,9 +113,7 @@ describe("OpenAI Chat route", () => {
it.effect("applies serializable HTTP overlays after payload lowering", () =>
LLMClient.generate(
LLM.updateRequest(request, {
model: model.route
.with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
.model({ id: model.id }),
model: OpenAIChat.model({ ...model, apiKey: "fresh-key", headers: { authorization: "Bearer stale" } }),
http: {
body: { metadata: { source: "test" } },
headers: { authorization: "Bearer request", "x-custom": "yes" },
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { LLMClient } from "../../src/route"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
@@ -12,13 +12,13 @@ import { sseEvents } from "../lib/sse"
const Json = Schema.fromJsonString(Schema.Unknown)
const decodeJson = Schema.decodeUnknownSync(Json)
const model = OpenAICompatibleChat.route
.with({
provider: "deepseek",
endpoint: { baseURL: "https://api.deepseek.test/v1/", query: { "api-version": "2026-01-01" } },
auth: Auth.bearer("test-key"),
})
.model({ id: "deepseek-chat" })
const model = OpenAICompatibleChat.model({
id: "deepseek-chat",
provider: "deepseek",
baseURL: "https://api.deepseek.test/v1/",
apiKey: "test-key",
queryParams: { "api-version": "2026-01-01" },
})
const request = LLM.request({
id: "req_1",
@@ -63,11 +63,10 @@ describe("OpenAI-compatible Chat route", () => {
expect(prepared.model).toMatchObject({
id: "deepseek-chat",
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(prepared.model.route.endpoint).toMatchObject({
route: "openai-compatible-chat",
baseURL: "https://api.deepseek.test/v1/",
query: { "api-version": "2026-01-01" },
apiKey: "test-key",
queryParams: { "api-version": "2026-01-01" },
})
expect(prepared.body).toEqual({
model: "deepseek-chat",
@@ -94,12 +93,13 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
const model = family.model(`${provider}-model`, { apiKey: "test-key" })
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
route: model.route,
baseURL: model.baseURL,
apiKey: model.apiKey,
}
}),
).toEqual(
@@ -108,20 +108,19 @@ describe("OpenAI-compatible Chat route", () => {
provider,
route: "openai-compatible-chat",
baseURL,
apiKey: "test-key",
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
const custom = OpenAICompatible.deepseek.model("deepseek-chat", {
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
route: "openai-compatible-chat",
baseURL: "https://custom.deepseek.test/v1",
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
@@ -2,13 +2,14 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
const model = OpenAI.configure({
const model = OpenAIResponses.model({
id: "gpt-4.1-mini",
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
}).responses("gpt-4.1-mini")
})
// OpenAI caches prefixes automatically once they cross the 1024-token threshold;
// `CacheHint` is a no-op for the wire body. The stable signal is the
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -11,9 +11,11 @@ import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
const model = OpenAIResponses.model({
id: "gpt-4.1-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const request = LLM.request({
id: "req_1",
@@ -47,9 +49,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(request, {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }),
}),
)
@@ -95,12 +95,10 @@ describe("OpenAI Responses route", () => {
)
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }),
prompt: "Say hello.",
}),
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
).pipe(Effect.provide(LLMClient.layerWithWebSocket.pipe(Layer.provide(deps))))
expect(response.text).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
@@ -115,6 +113,33 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("requires WebSocket runtime for OpenAI Responses WebSocket", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({
model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({
execute: () => Effect.die("unexpected HTTP request"),
}),
),
),
),
),
Effect.flip,
)
expect(error.message).toContain("requires WebSocketExecutor.Service")
}),
)
it.effect("fails immediately when WebSocket is already closed", () =>
Effect.gen(function* () {
const error = yield* WebSocketExecutor.fromWebSocket(
@@ -130,7 +155,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
yield* LLMClient.generate(
LLM.updateRequest(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
model: OpenAIResponses.model({ ...model, queryParams: { "api-version": "v1" } }),
}),
).pipe(
Effect.provide(
@@ -152,18 +177,17 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
yield* LLMClient.generate(
LLM.updateRequest(request, {
model: Azure.configure({
model: Azure.responses("gpt-4.1-mini", {
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"),
}),
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/responses?api-version=v1")
expect(web.headers.get("api-key")).toBe("azure-key")
expect(web.headers.get("authorization")).toBeNull()
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
@@ -179,7 +203,7 @@ describe("OpenAI Responses route", () => {
it.effect("loads OpenAI default auth from Effect Config", () =>
LLMClient.generate(
LLM.updateRequest(request, {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
model: OpenAI.responses("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/" }),
}),
).pipe(
configEnv({ OPENAI_API_KEY: "env-key" }),
@@ -200,10 +224,10 @@ describe("OpenAI Responses route", () => {
it.effect("lets explicit auth override OpenAI default API key auth", () =>
LLMClient.generate(
LLM.updateRequest(request, {
model: OpenAI.configure({
model: OpenAI.responses("gpt-4.1-mini", {
baseURL: "https://api.openai.test/v1/",
auth: Auth.bearer("oauth-token"),
}).responses("gpt-4.1-mini"),
}),
}),
).pipe(
Effect.provide(
@@ -250,7 +274,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
model: OpenAI.model("gpt-5.2", { baseURL: "https://api.openai.test/v1/" }),
prompt: "think",
providerOptions: {
openai: {
@@ -271,15 +295,14 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("request OpenAI provider options override route defaults", () =>
it.effect("request OpenAI provider options override model defaults", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({
model: OpenAI.model("gpt-4.1-mini", {
baseURL: "https://api.openai.test/v1/",
apiKey: "test",
providerOptions: { openai: { promptCacheKey: "model_cache" } },
}).model("gpt-4.1-mini"),
}),
prompt: "no cache",
providerOptions: { openai: { promptCacheKey: "request_cache" } },
}),
@@ -509,36 +532,17 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers user image content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
},
])
}),
)
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Responses user media content only supports images")
expect(error.message).toContain("OpenAI Responses user messages only support text content for now")
}),
)
@@ -8,14 +8,15 @@ import { it } from "../lib/effect"
describe("OpenRouter", () => {
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
const model = OpenRouter.model("openai/gpt-4o-mini", { apiKey: "test-key" })
expect(model).toMatchObject({
id: "openai/gpt-4o-mini",
provider: "openrouter",
route: { id: "openrouter" },
route: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
apiKey: "test-key",
})
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
@@ -32,8 +33,7 @@ describe("OpenRouter", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
model: OpenRouter.model("anthropic/claude-3.7-sonnet:thinking", {
providerOptions: {
openrouter: {
usage: true,
@@ -41,7 +41,7 @@ describe("OpenRouter", () => {
promptCacheKey: "session_123",
},
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
}),
prompt: "Think briefly.",
}),
)
+4 -5
View File
@@ -1,7 +1,7 @@
import type { HttpRecorder } from "@opencode-ai/http-recorder"
import { describe, type TestOptions } from "bun:test"
import { Effect } from "effect"
import type { Model } from "../src"
import type { ModelRef } from "../src"
import { goldenScenarioTags, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios"
import { recordedTests } from "./recorded-test"
import { kebab } from "./recorded-utils"
@@ -22,7 +22,7 @@ type ScenarioInput =
type TargetInput = {
readonly name: string
readonly model: Model
readonly model: ModelRef
readonly protocol?: string
readonly requires?: ReadonlyArray<string>
readonly transport?: Transport
@@ -38,20 +38,19 @@ const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { i
const scenarioTitle = (id: GoldenScenarioID) => {
if (id === "text") return "streams text"
if (id === "tool-call") return "streams tool call"
if (id === "image") return "reads image text"
return "drives a tool loop"
}
const defaultPrefix = (target: TargetInput) => {
if (target.prefix) return target.prefix
const transport = target.transport === "websocket" ? "-websocket" : ""
return `${target.model.provider}-${target.protocol ?? target.model.route.id}${transport}`
return `${target.model.provider}-${target.protocol ?? target.model.route}${transport}`
}
const metadata = (target: TargetInput) => ({
provider: target.model.provider,
protocol: target.protocol,
route: target.model.route.id,
route: target.model.route,
transport: target.transport ?? "http",
model: target.model.id,
...target.metadata,
+9 -67
View File
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMEvent, LLMResponse, Message, ToolChoice, ToolDefinition, type LLMRequest, type Model } from "../src"
import { LLM, LLMEvent, LLMResponse, ToolChoice, ToolDefinition, type LLMRequest, type ModelRef } from "../src"
import { LLMClient } from "../src/route"
import { tool } from "../src/tool"
@@ -41,7 +41,7 @@ export const weatherRuntimeTool = tool({
export const textRequest = (input: {
readonly id: string
readonly model: Model
readonly model: ModelRef
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
@@ -52,17 +52,15 @@ export const textRequest = (input: {
system: "You are concise.",
prompt: input.prompt ?? "Reply with exactly: Hello!",
cache: "none",
providerOptions:
input.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
generation:
input.temperature === false
? { maxTokens: input.maxTokens ?? 80 }
: { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
? { maxTokens: input.maxTokens ?? 20 }
: { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 },
})
export const weatherToolRequest = (input: {
readonly id: string
readonly model: Model
readonly model: ModelRef
readonly maxTokens?: number
readonly temperature?: number | false
}) =>
@@ -82,7 +80,7 @@ export const weatherToolRequest = (input: {
export const weatherToolLoopRequest = (input: {
readonly id: string
readonly model: Model
readonly model: ModelRef
readonly system?: string
readonly maxTokens?: number
readonly temperature?: number | false
@@ -101,7 +99,7 @@ export const weatherToolLoopRequest = (input: {
export const goldenWeatherToolLoopRequest = (input: {
readonly id: string
readonly model: Model
readonly model: ModelRef
readonly maxTokens?: number
readonly temperature?: number | false
}) =>
@@ -110,39 +108,6 @@ export const goldenWeatherToolLoopRequest = (input: {
system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.",
})
const RESTROOM_IMAGE_TEXT = "jiggling restroom prison"
const restroomImage = () =>
Effect.promise(() => Bun.file(new URL("./fixtures/media/restroom.png", import.meta.url)).bytes()).pipe(
Effect.map((bytes) => Buffer.from(bytes).toString("base64")),
)
export const imageRequest = (input: {
readonly id: string
readonly model: Model
readonly image: string
readonly maxTokens?: number
readonly temperature?: number | false
}) =>
LLM.request({
id: input.id,
model: input.model,
system: "Read images carefully. Reply only with the visible text.",
messages: [
Message.user([
{
type: "text",
text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.",
},
{ type: "media", mediaType: "image/png", data: input.image },
]),
],
cache: "none",
generation:
input.temperature === false
? { maxTokens: input.maxTokens ?? 20 }
: { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 },
})
export const runWeatherToolLoop = (request: LLMRequest) =>
LLMClient.stream({
request,
@@ -193,28 +158,20 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
}
export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" | "image"
export type GoldenScenarioID = "text" | "tool-call" | "tool-loop"
export interface GoldenScenarioContext {
readonly id: string
readonly model: Model
readonly model: ModelRef
readonly maxTokens?: number
readonly temperature?: number | false
}
const generate = (request: LLMRequest) => LLMClient.generate(request)
const normalizeImageText = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z\s]/g, "")
.replace(/\s+/g, " ")
.trim()
export const goldenScenarioTags = (id: GoldenScenarioID) => {
if (id === "text") return ["text", "golden"]
if (id === "tool-call") return ["tool", "tool-call", "golden"]
if (id === "image") return ["media", "image", "vision", "golden"]
return ["tool", "tool-loop", "golden"]
}
@@ -249,21 +206,6 @@ export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioC
return
}
if (id === "image") {
const response = yield* generate(
imageRequest({
id: context.id,
model: context.model,
image: yield* restroomImage(),
maxTokens: context.maxTokens ?? 20,
temperature: context.temperature,
}),
)
expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
expectFinish(response.events, "stop")
return
}
expectGoldenWeatherToolLoop(
yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
+3 -1
View File
@@ -69,6 +69,8 @@ export const recordedTests = (options: RecordedTestsOptions) =>
requestExecutor,
webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }),
)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService))
return Layer.mergeAll(deps, LLMClient.layerWithWebSocket.pipe(Layer.provide(deps))).pipe(
Layer.provide(cassetteService),
)
},
})
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { Auth } from "../src/route"
describe("Route.with", () => {
test("merges endpoint query and header defaults while replacing auth and id", () => {
const auth = Auth.headers({ "x-auth": "new" })
const route = OpenAIChat.route
.with({
id: "base-chat",
endpoint: {
baseURL: "https://api.example.test/v1",
query: { keep: "base", base: "1" },
},
headers: { "x-base": "base", "x-override": "base" },
auth: Auth.headers({ "x-auth": "old" }),
})
.with({
id: "patched-chat",
endpoint: { query: { keep: "patch", patch: "1" } },
headers: { "x-override": "patch", "x-patch": "patch" },
auth,
})
expect(route.id).toBe("patched-chat")
expect(route.auth).toBe(auth)
expect(route.endpoint).toMatchObject({
baseURL: "https://api.example.test/v1",
path: "/chat/completions",
query: { keep: "patch", base: "1", patch: "1" },
})
expect(route.defaults.headers).toEqual({
"x-base": "base",
"x-override": "patch",
"x-patch": "patch",
})
expect(route.defaults.http?.headers).toEqual({
"x-base": "base",
"x-override": "patch",
"x-patch": "patch",
})
})
})
+10 -13
View File
@@ -1,19 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema"
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new Model({
const model = new ModelRef({
id: ModelID.make("fake-model"),
provider: ProviderID.make("fake-provider"),
route: OpenAIChat.route,
route: "openai-chat",
baseURL: "https://fake.local",
limits: new ModelLimits({}),
})
const decodeLLMRequest = Schema.decodeUnknownSync(LLMRequest as unknown as Schema.Decoder<LLMRequest>)
const decodeLLMEvent = Schema.decodeUnknownSync(LLMEvent as unknown as Schema.Decoder<LLMEvent>)
describe("llm schema", () => {
test("decodes a minimal request", () => {
const input: unknown = {
@@ -25,26 +22,26 @@ describe("llm schema", () => {
generation: {},
}
const decoded = decodeLLMRequest(input)
const decoded = Schema.decodeUnknownSync(LLMRequest)(input)
expect(decoded.id).toBe("req_1")
expect(decoded.messages[0]?.content[0]?.type).toBe("text")
})
test("accepts custom route ids", () => {
const decoded = decodeLLMRequest({
model: Model.update(model, { route: OpenAIResponses.route }),
const decoded = Schema.decodeUnknownSync(LLMRequest)({
model: { ...model, route: "custom-route" },
system: [],
messages: [],
tools: [],
generation: {},
})
expect(decoded.model.route.id).toBe("openai-responses")
expect(decoded.model.route).toBe("custom-route")
})
test("rejects invalid event type", () => {
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow()
})
test("finish constructors accept usage input", () => {
+8 -49
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } from "../src"
import { Auth, LLMClient } from "../src/route"
import { LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
@@ -12,9 +12,11 @@ import { dynamicResponse, scriptedResponses } from "./lib/http"
import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks"
import { sseEvents } from "./lib/sse"
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const model = OpenAIChat.model({
id: "gpt-4o-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const Json = Schema.fromJsonString(Schema.Unknown)
const decodeJson = Schema.decodeUnknownSync(Json)
@@ -139,45 +141,6 @@ describe("LLMClient tools", () => {
}),
)
it.effect("preserves content tool results from dynamic tools", () =>
Effect.gen(function* () {
const screenshot = tool({
description: "Capture a screenshot.",
jsonSchema: { type: "object", properties: {} },
execute: () =>
Effect.succeed({
type: "content" as const,
value: [
{ type: "text" as const, text: "Screenshot captured." },
{ type: "media" as const, mediaType: "image/png", data: "AAAA" },
],
}),
})
const events = Array.from(
yield* LLMClient.stream({ request: baseRequest, tools: { screenshot } }).pipe(
Stream.runCollect,
Effect.provide(
scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]),
),
),
)
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
type: "tool-result",
id: "call_1",
name: "screenshot",
result: {
type: "content",
value: [
{ type: "text", text: "Screenshot captured." },
{ type: "media", mediaType: "image/png", data: "AAAA" },
],
},
})
}),
)
it.effect("executes tool calls for one step without looping by default", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
@@ -286,9 +249,7 @@ describe("LLMClient tools", () => {
yield* TestToolRuntime.runTools({
request: LLM.updateRequest(baseRequest, {
model: AnthropicMessages.route
.with({ auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" }),
model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }),
}),
tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer))
@@ -535,9 +496,7 @@ describe("LLMClient tools", () => {
const events = Array.from(
yield* TestToolRuntime.runTools({
request: LLM.updateRequest(baseRequest, {
model: AnthropicMessages.route
.with({ auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-sonnet-4-5" }),
model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }),
}),
tools: {},
}).pipe(Stream.runCollect, Effect.provide(layer)),
+1 -2
View File
@@ -1,11 +1,10 @@
import { Effect, Schema } from "effect"
import { LLM } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { Auth } from "../src/route"
import { tool } from "../src/tool"
const request = LLM.request({
model: OpenAIChat.route.with({ auth: Auth.bearer("fixture") }).model({ id: "gpt-4o-mini" }),
model: OpenAIChat.model({ id: "gpt-4o-mini", apiKey: "fixture" }),
prompt: "Use the tool.",
})
+2
View File
@@ -96,12 +96,14 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
@@ -1,21 +1,16 @@
import { Effect, ManagedRuntime } from "effect"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { AppRuntime } from "@/effect/app-runtime"
import { type InstanceContext } from "./instance-context"
import { InstanceLayer } from "./instance-layer"
import { InstanceStore, type LoadInput } from "./instance-store"
// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service.
// Delete this module once those callers are migrated to Effect boundaries that
// provide InstanceStore directly.
const runtime = ManagedRuntime.make(InstanceLayer.layer, { memoMap })
const run = <A, E>(fn: (store: InstanceStore.Interface) => Effect.Effect<A, E>) =>
runtime.runPromise(InstanceStore.Service.use(fn))
export const load = (input: LoadInput) => run((store) => store.load(input))
export const disposeInstance = (ctx: InstanceContext) => run((store) => store.dispose(ctx))
export const disposeAllInstances = () => run((store) => store.disposeAll())
export const reloadInstance = (input: LoadInput) => run((store) => store.reload(input))
export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input)))
export const disposeInstance = (ctx: InstanceContext) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
export const reloadInstance = (input: LoadInput) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
export * as InstanceRuntime from "./instance-runtime"
+2 -10
View File
@@ -4,7 +4,7 @@ import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
import type { LLMEvent } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import type { LLMClientService } from "@opencode-ai/llm/route"
import { mergeDeep } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
@@ -349,8 +349,6 @@ const live: Layer.Layer<
...headers,
}
// Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
// either returns a ready LLMEvent stream or a concrete fallback reason.
if (flags.experimentalNativeLlm) {
const native = LLMNativeRuntime.stream({
model: input.model,
@@ -401,8 +399,6 @@ const live: Layer.Layer<
"llm.model": input.model.id,
}),
)
// Default runtime path: AI SDK owns provider execution and tool dispatch;
// LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.
return {
type: "ai-sdk" as const,
result: streamText({
@@ -485,8 +481,6 @@ const live: Layer.Layer<
if (result.type === "native") return result.stream
// Adapter seam: both runtimes expose the same LLMEvent stream. Native
// already returns one; AI SDK streams are converted here.
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
@@ -510,9 +504,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
),
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
Layer.provide(RuntimeFlags.defaultLayer),
),
)
+2 -25
View File
@@ -1,6 +1,6 @@
# Session LLM Runtime Boundaries
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection. It is the only file in this area that should know about the full session request shape.
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.
This folder contains adapters behind that service boundary:
@@ -8,29 +8,6 @@ This folder contains adapters behind that service boundary:
- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests.
- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`.
## File Structure
```txt
src/session/
llm.ts session-owned orchestration and runtime selection
llm/
AGENTS.md boundary notes for the adapter layer
ai-sdk.ts AI SDK fullStream -> @opencode-ai/llm LLMEvent adapter
native-request.ts opencode/AI SDK-shaped input -> @opencode-ai/llm LLMRequest
native-runtime.ts native runtime gate, tool bridge, and LLMClient handoff
```
Integration points:
- `../llm.ts` imports `LLMClient` from `@opencode-ai/llm/route`; native execution is the only path that calls it directly.
- `../llm.ts` imports `LLMAISDK` from `./llm/ai-sdk`; the AI SDK path still calls `streamText(...)` locally, then adapts `result.fullStream` into shared `LLMEvent`s.
- `../llm.ts` imports `LLMNativeRuntime` from `./llm/native-runtime`; this is the runtime-selection seam. Unsupported native requests return a reason and fall back to AI SDK.
- `native-runtime.ts` imports `LLMNative` from `./native-request`; this keeps request lowering separate from transport and tool execution.
- `native-request.ts` is the only adapter file that should construct `LLM.request(...)`, `LLM.model(...)`, `Message.*`, `SystemPart`, `ToolCallPart`, `ToolResultPart`, or `ToolDefinition` values from `@opencode-ai/llm`.
- `ai-sdk.ts` and `native-runtime.ts` both emit `@opencode-ai/llm` `LLMEvent`s so downstream session processing does not care which runtime handled the request.
Keep new integration code on one of these seams. Avoid importing session services into `native-request.ts`; pass normalized data through `RequestInput` instead.
## Runtime selection
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
@@ -86,5 +63,5 @@ Safety boundary:
- AI SDK remains the default.
- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` or the umbrella `OPENCODE_EXPERIMENTAL=true` opts in. Native is not a global replacement.
- Native execution currently supports OpenAI, opencode-managed OpenAI-compatible, and Anthropic API-key paths backed by `@ai-sdk/openai`, `@ai-sdk/openai-compatible`, or `@ai-sdk/anthropic` catalog entries.
- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config.
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.
@@ -1,14 +1,6 @@
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
import {
AmazonBedrock,
Anthropic,
Azure,
Google,
OpenAI,
OpenAICompatible,
OpenRouter,
} from "@opencode-ai/llm/providers"
import "@opencode-ai/llm/providers"
import type { ModelMessage } from "ai"
import type { Provider } from "@/provider/provider"
import { isRecord } from "@/util/record"
@@ -34,6 +26,24 @@ export type RequestInput = {
readonly headers?: Record<string, string>
}
const DEFAULT_BASE_URL: Record<string, string> = {
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
}
const ROUTE: Record<string, string> = {
"@ai-sdk/openai": "openai-responses",
"@ai-sdk/azure": "azure-openai-responses",
"@ai-sdk/anthropic": "anthropic-messages",
"@ai-sdk/google": "gemini",
"@ai-sdk/amazon-bedrock": "bedrock-converse",
"@ai-sdk/openai-compatible": "openai-compatible-chat",
"@openrouter/ai-sdk-provider": "openrouter",
}
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
if (!isRecord(value)) return undefined
const result = Object.fromEntries(
@@ -137,46 +147,33 @@ const generation = (input: RequestInput) => {
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
const baseURL = (input: Provider.Model | RequestInput) =>
"model" in input ? (input.baseURL ?? (input.model.api.url || undefined)) : input.api.url || undefined
const requireBaseURL = (model: Provider.Model, url: string | undefined) => {
if (url) return url
const baseURL = (model: Provider.Model) => {
if (model.api.url) return model.api.url
const fallback = DEFAULT_BASE_URL[model.api.npm]
if (fallback) return fallback
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
}
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
const model = "model" in input ? input.model : input
const url = baseURL(input)
const options = {
...("model" in input && input.apiKey ? { apiKey: input.apiKey } : {}),
...(url ? { baseURL: url } : {}),
const route = ROUTE[model.api.npm]
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
return LLM.model({
id: model.api.id,
provider: model.providerID,
route,
baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model),
apiKey: "model" in input ? input.apiKey : undefined,
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
limits: {
context: model.limit.context,
output: model.limit.output,
},
}
if (model.api.npm === "@ai-sdk/openai") return OpenAI.configure(options).responses(model.api.id)
if (model.api.npm === "@ai-sdk/azure")
return Azure.configure({ ...options, baseURL: requireBaseURL(model, url) }).responses(model.api.id)
if (model.api.npm === "@ai-sdk/anthropic") return Anthropic.configure(options).model(model.api.id)
if (model.api.npm === "@ai-sdk/google") return Google.configure(options).model(model.api.id)
if (model.api.npm === "@ai-sdk/amazon-bedrock") return AmazonBedrock.configure(options).model(model.api.id)
if (model.api.npm === "@ai-sdk/openai-compatible")
return OpenAICompatible.configure({
...options,
provider: String(model.providerID),
baseURL: requireBaseURL(model, url),
}).model(model.api.id)
if (model.api.npm === "@openrouter/ai-sdk-provider") return OpenRouter.configure(options).model(model.api.id)
throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
})
}
export const request = (input: RequestInput) => {
const converted = messages(input.messages)
// This is the only native adapter boundary that should construct canonical
// @opencode-ai/llm request objects from opencode's session/AI SDK-shaped data.
return LLM.request({
model: model(input, input.headers),
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
@@ -41,8 +41,8 @@ export function status(input: Pick<StreamInput, "model" | "provider" | "auth">):
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI or Anthropic" }
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
@@ -59,8 +59,6 @@ export function stream(input: StreamInput): StreamResult {
const current = status(input)
if (current.type === "unsupported") return current
// Integration point with @opencode-ai/llm: native-request lowers session data
// into an LLMRequest, then LLMClient handles route selection and transport.
return {
...current,
stream: input.llmClient.stream({
@@ -101,8 +99,6 @@ export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput
return Object.fromEntries(
Object.entries(tools).map(([name, item]) => [
name,
// Tool execution remains opencode-owned. The native runtime only adapts
// the @opencode-ai/llm tool call back into the AI SDK Tool.execute shape.
nativeTool({
description: item.description ?? "",
jsonSchema: nativeSchema(item.inputSchema),
+2 -4
View File
@@ -278,11 +278,9 @@ export const layer = Layer.effect(
return { call: ctx.toolcalls[input.id], part }
})
const isFilePart = (value: unknown): value is MessageV2.FilePart => Schema.is(MessageV2.FilePart)(value)
const isFilePart = Schema.is(MessageV2.FilePart)
const toolResultOutput = (
value: Extract<StreamEvent, { type: "tool-result" }>,
): { title: string; metadata: Record<string, any>; output: string; attachments?: MessageV2.FilePart[] } => {
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
return {
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
+24 -189
View File
@@ -1,9 +1,6 @@
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
import * as Database from "@/storage/db"
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { SessionMessage } from "@opencode-ai/core/session-message"
import type { Prompt } from "@opencode-ai/core/session-prompt"
import { ProjectID } from "@/project/schema"
@@ -13,7 +10,8 @@ import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionStorage } from "./storage/session"
import { SessionStorageSql } from "./storage/session-sql"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
@@ -73,40 +71,17 @@ export interface Interface {
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly get: (sessionID: SessionID) => Effect.Effect<Info, NotFoundError>
readonly list: (input: {
limit?: number
order?: "asc" | "desc"
directory?: string
path?: string
workspaceID?: WorkspaceID
roots?: boolean
start?: number
search?: string
cursor?: {
id: SessionID
time: number
direction: "previous" | "next"
}
}) => Effect.Effect<Info[], never>
readonly messages: (input: {
sessionID: SessionID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
time: number
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], never>
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], never>
readonly list: (input: SessionStorage.SessionListInput) => Effect.Effect<Info[]>
readonly messages: (input: SessionStorage.MessageListInput) => Effect.Effect<SessionMessage.Message[]>
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[]>
readonly prompt: (input: {
id?: EventV2.ID
sessionID: SessionID
prompt: Prompt
delivery?: Delivery
}) => Effect.Effect<SessionMessage.User, never>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
}) => Effect.Effect<SessionMessage.User>
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void>
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void>
readonly subagent: (input: {
id?: EventV2.ID
parentID: SessionID
@@ -114,10 +89,10 @@ export interface Interface {
agent: string
model?: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void>
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void>
readonly compact: (sessionID: SessionID) => Effect.Effect<void>
readonly wait: (sessionID: SessionID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
@@ -126,170 +101,28 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
function fromRow(row: typeof SessionTable.$inferSelect): Info {
return new Info({
id: SessionID.make(row.id),
projectID: ProjectID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
title: row.title,
parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined,
path: row.path ?? "",
agent: row.agent ?? undefined,
model: row.model
? {
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
const storage = yield* SessionStorage.Service
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (_input) {
return {} as any
return yield* Effect.die(new Error("V2Session.create is not implemented"))
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
const row = yield* storage.get(sessionID).pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID })
return fromRow(row)
return new Info(row)
}),
list: Effect.fn("V2Session.list")(function* (input) {
const direction = input.cursor?.direction ?? "next"
let order = input.order ?? "desc"
// This is a load bearing sort, desktop relies on this
const sortColumn = SessionTable.time_updated
// Query the adjacent rows in reverse, then flip them back into the requested order below.
if (direction === "previous" && order === "asc") order = "desc"
if (direction === "previous" && order === "desc") order = "asc"
const conditions: SQL[] = []
if (input.directory) conditions.push(eq(SessionTable.directory, input.directory))
if (input.path)
conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!)
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if (input.roots) conditions.push(isNull(SessionTable.parent_id))
if (input.start) conditions.push(gte(sortColumn, input.start))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.cursor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.cursor.time),
and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
)!
: or(
lt(sortColumn, input.cursor.time),
and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
)!,
)
}
const query = Database.Client()
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
return (yield* storage.list(input).pipe(Effect.orDie)).map((row) => new Info(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
const direction = input.cursor?.direction ?? "next"
let order = input.order ?? "desc"
// Query the adjacent rows in reverse, then flip them back into the requested order below.
if (direction === "previous" && order === "asc") order = "desc"
if (direction === "previous" && order === "desc") order = "asc"
const boundary = input.cursor
? order === "asc"
? or(
gt(SessionMessageTable.time_created, input.cursor.time),
and(
eq(SessionMessageTable.time_created, input.cursor.time),
gt(SessionMessageTable.id, input.cursor.id),
),
)
: or(
lt(SessionMessageTable.time_created, input.cursor.time),
and(
eq(SessionMessageTable.time_created, input.cursor.time),
lt(SessionMessageTable.id, input.cursor.id),
),
)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const rows = Database.use((db) => {
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(
order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
)
const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
return direction === "previous" ? rows.toReversed() : rows
})
return rows.map((row) => decode(row))
return yield* storage.messages(input).pipe(Effect.orDie)
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
const rows = Database.use((db) => {
const compaction = db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
.limit(1)
.get()
return db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction
? or(
gt(SessionMessageTable.time_created, compaction.time_created),
and(
eq(SessionMessageTable.time_created, compaction.time_created),
gte(SessionMessageTable.id, compaction.id),
),
)
: undefined,
),
)
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
.all()
})
return rows.map((row) => decode(row))
return yield* storage.context(sessionID).pipe(Effect.orDie)
}),
prompt: Effect.fn("V2Session.prompt")(function* (_input) {
return {} as any
return yield* Effect.die(new Error("V2Session.prompt is not implemented"))
}),
shell: Effect.fn("V2Session.shell")(function* (_input) {}),
skill: Effect.fn("V2Session.skill")(function* (_input) {}),
@@ -336,6 +169,8 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Layer.mergeAll(EventV2Bridge.defaultLayer, SessionStorageSql.defaultLayer)),
)
export * as SessionV2 from "./session"
@@ -0,0 +1,37 @@
import { Database as LegacyDatabase } from "@/storage/db"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Context, Effect, Layer } from "effect"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
export class Service extends Context.Service<Service, DatabaseShape>()("@opencode/v2/storage/Database") {}
export const layerForPath = (filename: string) =>
Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
return db
}),
).pipe(Layer.provide(SqliteClient.layer({ filename })))
export const layer = Layer.unwrap(
Effect.sync(() => {
// TODO: Extract migration/bootstrap from the legacy Database.Client() so V2 storage
// can ensure the schema exists without opening the old global Drizzle connection.
LegacyDatabase.Client()
return layerForPath(LegacyDatabase.getPath())
}),
)
export const defaultLayer = layer
export * as StorageDatabase from "./database"
@@ -0,0 +1,101 @@
import { DateTime, Effect, Layer } from "effect"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionStorage } from "./session"
export const make = (state: {
readonly sessions: Map<string, SessionStorage.SessionRow>
readonly messages: Map<string, SessionMessage.Message[]>
}) =>
SessionStorage.Service.of({
get: (sessionID) => Effect.sync(() => state.sessions.get(sessionID)),
list: (input) =>
Effect.sync(() => {
const direction = input.cursor?.direction ?? "next"
const order = SessionStorage.pageOrder(input.order ?? "desc", direction)
const rows = Array.from(state.sessions.values())
.filter((row) => {
if (input.directory && row.directory !== input.directory) return false
if (input.path && row.path !== input.path && !row.path?.startsWith(`${input.path}/`)) return false
if (input.workspaceID && row.workspaceID !== input.workspaceID) return false
if (input.roots && row.parentID) return false
if (input.start && DateTime.toEpochMillis(row.time.updated) < input.start) return false
if (input.search && !row.title.includes(input.search)) return false
if (!input.cursor) return true
return compareCursor(row.id, DateTime.toEpochMillis(row.time.updated), input.cursor, order)
})
.toSorted((a, b) =>
compareRows(
a.id,
DateTime.toEpochMillis(a.time.updated),
b.id,
DateTime.toEpochMillis(b.time.updated),
order,
),
)
const limited = input.limit === undefined ? rows : rows.slice(0, input.limit)
return direction === "previous" ? limited.toReversed() : limited
}),
messages: (input) =>
Effect.sync(() => {
const direction = input.cursor?.direction ?? "next"
const order = SessionStorage.pageOrder(input.order ?? "desc", direction)
const rows = (state.messages.get(input.sessionID) ?? [])
.filter((message) => {
if (!input.cursor) return true
return compareCursor(message.id, DateTime.toEpochMillis(message.time.created), input.cursor, order)
})
.toSorted((a, b) =>
compareRows(
a.id,
DateTime.toEpochMillis(a.time.created),
b.id,
DateTime.toEpochMillis(b.time.created),
order,
),
)
const limited = input.limit === undefined ? rows : rows.slice(0, input.limit)
return direction === "previous" ? limited.toReversed() : limited
}),
context: (sessionID) =>
Effect.sync(() => {
const messages = (state.messages.get(sessionID) ?? []).toSorted((a, b) =>
compareRows(
a.id,
DateTime.toEpochMillis(a.time.created),
b.id,
DateTime.toEpochMillis(b.time.created),
"asc",
),
)
const index = messages.findLastIndex((message) => message.type === "compaction")
return index === -1 ? messages : messages.slice(index)
}),
})
const storageLayer = Layer.sync(SessionStorage.Service, () => {
return make({
sessions: new Map(),
messages: new Map(),
})
})
export const layer = storageLayer
export const defaultLayer = layer
function compareCursor(
id: string,
time: number,
cursor: { readonly id: string; readonly time: number },
order: SessionStorage.SortOrder,
) {
if (order === "asc") return time > cursor.time || (time === cursor.time && id > cursor.id)
return time < cursor.time || (time === cursor.time && id < cursor.id)
}
function compareRows(aID: string, aTime: number, bID: string, bTime: number, order: SessionStorage.SortOrder) {
const result = aTime === bTime ? aID.localeCompare(bID) : aTime - bTime
return order === "asc" ? result : -result
}
export * as SessionStorageMemory from "./session-memory"
@@ -0,0 +1,164 @@
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { Effect, Layer, Schema } from "effect"
import { StorageDatabase } from "./database"
import { SessionStorage } from "./session"
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const decodeSessionRow = Schema.decodeUnknownSync(SessionStorage.SessionRow)
const mapStorageError = Effect.mapError(
(cause) => new SessionStorage.StorageError({ message: "Session storage SQL operation failed", cause }),
)
export const layer = Layer.effect(
SessionStorage.Service,
Effect.gen(function* () {
const db = yield* StorageDatabase.Service
const get: SessionStorage.Interface["get"] = Effect.fn("SessionStorageSql.get")(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()
return row ? fromSessionRow(row) : undefined
}, mapStorageError)
const list: SessionStorage.Interface["list"] = Effect.fn("SessionStorageSql.list")(function* (input) {
const direction = input.cursor?.direction ?? "next"
const order = SessionStorage.pageOrder(input.order ?? "desc", direction)
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if (input.directory) conditions.push(eq(SessionTable.directory, input.directory))
if (input.path)
conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!)
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if (input.roots) conditions.push(isNull(SessionTable.parent_id))
if (input.start) conditions.push(gte(sortColumn, input.start))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.cursor) conditions.push(sessionCursorBoundary(input.cursor, order))
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* input.limit === undefined ? query : query.limit(input.limit)
return (direction === "previous" ? rows.toReversed() : rows).map(fromSessionRow)
}, mapStorageError)
const messages: SessionStorage.Interface["messages"] = Effect.fn("SessionStorageSql.messages")(function* (input) {
const direction = input.cursor?.direction ?? "next"
const order = SessionStorage.pageOrder(input.order ?? "desc", direction)
const boundary = input.cursor ? messageCursorBoundary(input.cursor, order) : undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(
order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
)
const rows = yield* input.limit === undefined ? query : query.limit(input.limit)
return (direction === "previous" ? rows.toReversed() : rows).map((row) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }),
)
}, mapStorageError)
const context: SessionStorage.Interface["context"] = Effect.fn("SessionStorageSql.context")((sessionID) =>
Effect.gen(function* () {
const compaction = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
.limit(1)
.get()
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction
? or(
gt(SessionMessageTable.time_created, compaction.time_created),
and(
eq(SessionMessageTable.time_created, compaction.time_created),
gte(SessionMessageTable.id, compaction.id),
),
)
: undefined,
),
)
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
return rows.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
}).pipe(mapStorageError),
)
return SessionStorage.Service.of({ get, list, messages, context })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(StorageDatabase.defaultLayer.pipe(Layer.orDie)))
function sessionCursorBoundary(cursor: SessionStorage.SessionCursor, order: SessionStorage.SortOrder) {
if (order === "asc")
return or(
gt(SessionTable.time_updated, cursor.time),
and(eq(SessionTable.time_updated, cursor.time), gt(SessionTable.id, cursor.id)),
)!
return or(
lt(SessionTable.time_updated, cursor.time),
and(eq(SessionTable.time_updated, cursor.time), lt(SessionTable.id, cursor.id)),
)!
}
function messageCursorBoundary(cursor: SessionStorage.MessageCursor, order: SessionStorage.SortOrder) {
if (order === "asc")
return or(
gt(SessionMessageTable.time_created, cursor.time),
and(eq(SessionMessageTable.time_created, cursor.time), gt(SessionMessageTable.id, cursor.id)),
)!
return or(
lt(SessionMessageTable.time_created, cursor.time),
and(eq(SessionMessageTable.time_created, cursor.time), lt(SessionMessageTable.id, cursor.id)),
)!
}
function fromSessionRow(row: typeof SessionTable.$inferSelect) {
return decodeSessionRow({
id: row.id,
parentID: row.parent_id ?? undefined,
projectID: row.project_id,
workspaceID: row.workspace_id ?? undefined,
directory: row.directory,
title: row.title,
path: row.path ?? "",
agent: row.agent ?? undefined,
model: row.model ? { ...row.model, variant: row.model.variant ?? "default" } : undefined,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
time: {
created: row.time_created,
updated: row.time_updated,
archived: row.time_archived ?? undefined,
},
})
}
export * as SessionStorageSql from "./session-sql"
+100
View File
@@ -0,0 +1,100 @@
import { WorkspaceID } from "@/control-plane/schema"
import { ProjectID } from "@/project/schema"
import { SessionID } from "@/session/schema"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { ModelV2 } from "@opencode-ai/core/model"
import { Context, Effect, Schema } from "effect"
export const SortOrder = Schema.Literals(["asc", "desc"]).annotate({
identifier: "SortOrder",
})
export type SortOrder = typeof SortOrder.Type
export const PageDirection = Schema.Literals(["previous", "next"]).annotate({
identifier: "PageDirection",
})
export type PageDirection = typeof PageDirection.Type
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("StorageError", {
message: Schema.String,
cause: Schema.Defect,
}) {}
export class SessionRow extends Schema.Class<SessionRow>("SessionRow")({
id: SessionID,
parentID: Schema.optional(SessionID),
projectID: ProjectID,
workspaceID: Schema.optional(WorkspaceID),
directory: Schema.optional(Schema.String),
path: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(ModelV2.Ref),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: Schema.optional(V2Schema.DateTimeUtcFromMillis),
}),
title: Schema.String,
}) {}
export const SessionCursor = Schema.Struct({
id: SessionID,
time: Schema.Finite,
direction: PageDirection,
}).annotate({ identifier: "SessionCursor" })
export type SessionCursor = typeof SessionCursor.Type
export const SessionListInput = Schema.Struct({
limit: Schema.optional(Schema.Finite),
order: Schema.optional(SortOrder),
directory: Schema.optional(Schema.String),
path: Schema.optional(Schema.String),
workspaceID: Schema.optional(WorkspaceID),
roots: Schema.optional(Schema.Boolean),
start: Schema.optional(Schema.Finite),
search: Schema.optional(Schema.String),
cursor: Schema.optional(SessionCursor),
}).annotate({ identifier: "SessionListInput" })
export type SessionListInput = typeof SessionListInput.Type
export const MessageCursor = Schema.Struct({
id: SessionMessage.ID,
time: Schema.Finite,
direction: PageDirection,
}).annotate({ identifier: "MessageCursor" })
export type MessageCursor = typeof MessageCursor.Type
export const MessageListInput = Schema.Struct({
sessionID: SessionID,
limit: Schema.optional(Schema.Finite),
order: Schema.optional(SortOrder),
cursor: Schema.optional(MessageCursor),
}).annotate({ identifier: "MessageListInput" })
export type MessageListInput = typeof MessageListInput.Type
export interface Interface {
readonly get: (sessionID: SessionID) => Effect.Effect<SessionRow | undefined, StorageError>
readonly list: (input: SessionListInput) => Effect.Effect<SessionRow[], StorageError>
readonly messages: (input: MessageListInput) => Effect.Effect<SessionMessage.Message[], StorageError>
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], StorageError>
}
export function pageOrder(order: SortOrder, direction: PageDirection) {
if (direction !== "previous") return order
return order === "asc" ? "desc" : "asc"
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/session/Storage") {}
export * as SessionStorage from "./session"
@@ -5,8 +5,7 @@ import Http from "node:http"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Effect, Layer, Schema } from "effect"
import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -30,8 +29,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as Workspace from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceLayer } from "@/project/instance-layer"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Auth } from "@/auth"
import { SessionPrompt } from "@/session/prompt"
@@ -122,14 +121,9 @@ afterEach(async () => {
await resetDatabase()
})
const runtime = ManagedRuntime.make(
Layer.mergeAll(InstanceLayer.layer, Workspace.defaultLayer, SessionNs.defaultLayer),
{ memoMap },
)
async function withInstance<T>(fn: (ctx: InstanceContext) => T | Promise<T>) {
await using tmp = await tmpdir({ git: true })
const ctx = await runtime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path })))
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path })))
return await context.provide(ctx, () => fn(ctx))
}
@@ -155,7 +149,7 @@ function currentInstance() {
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => {
const ctx = currentInstance()
return runtime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
}
const createWorkspace = (input: Workspace.CreateInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
@@ -938,12 +932,12 @@ describe("workspace CRUD", () => {
const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous)
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
const session = await runtime.runPromise(
const session = await AppRuntime.runPromise(
SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)),
)
attachSessionToWorkspace(session.id, previous.id)
const workspaceCtx = await runtime.runPromise(
const workspaceCtx = await AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.load({ directory: workspaceTmp.path })),
)
const workspaceProjectID = await context.provide(workspaceCtx, async () => {
+3 -31
View File
@@ -1,9 +1,8 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Duration, Effect, Exit, Layer } from "effect"
import * as Scope from "effect/Scope"
import type * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import type { Config } from "@/config/config"
import { TestInstance, withTmpdirInstance } from "../fixture/fixture"
@@ -25,9 +24,7 @@ function instanceArgs(
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
type Runner = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) => Promise<A>
const isolatedRun: Runner = (value, layer) =>
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
@@ -38,25 +35,7 @@ const isolatedRun: Runner = (value, layer) =>
return yield* exit
}).pipe(Effect.runPromise)
// Builds the layer through the shared process-wide memoMap so cached services
// (Bus, Session, …) match Server.Default's instances. Use for tests that
// publish to an in-process HTTP server and need pub/sub identity with the
// server's handlers.
const sharedRun: Runner = (value, layer) =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const ctx = yield* Layer.buildWithMemoMap(layer, memoMap, scope)
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(ctx), Effect.exit)
yield* Scope.close(scope, Exit.void)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>, run: Runner = isolatedRun) => {
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, testLayer), opts)
@@ -131,13 +110,6 @@ export const it = make(testEnv, liveEnv)
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
// Variant of `testEffect` that builds the test layer through the shared
// process-wide memoMap so services like Bus/Session resolve to the same
// instances Server.Default uses. Use when a test needs pub/sub identity with
// an in-process HTTP server — most tests should stick with `testEffect`.
export const testEffectShared = <R, E>(layer: Layer.Layer<R, E>) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun)
export const awaitWithTimeout = <A, E, R>(
self: Effect.Effect<A, E, R>,
message: string,
-63
View File
@@ -1,63 +0,0 @@
import { Effect, Queue, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
export const SseEvent = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
export type SseEvent = Schema.Schema.Type<typeof SseEvent>
function decodeFrames(text: string): SseEvent[] {
return text
.split(/\n\n+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, ""))))
}
/**
* Opens a scoped subscription to the instance `/event` SSE stream and returns
* a Queue of decoded events. The underlying request and decoder fiber are
* released when the test scope closes.
*/
export const openInstanceEventStream = (directory: string) =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get(EventPaths.event).pipe(
HttpClientRequest.setHeader("x-opencode-directory", directory),
HttpClient.execute,
)
const queue = yield* Queue.unbounded<SseEvent>()
yield* response.stream.pipe(
Stream.decodeText({ encoding: "utf-8" }),
Stream.flatMap((text) => Stream.fromIterable(decodeFrames(text))),
Stream.runForEach((event) => Queue.offer(queue, event)),
Effect.forkScoped,
)
return queue
})
export const readNextEvent = (queue: Queue.Queue<SseEvent>) =>
Queue.take(queue).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () => Effect.fail(new Error("timed out reading SSE event")),
}),
)
export const collectUntilEvent = (queue: Queue.Queue<SseEvent>, predicate: (event: SseEvent) => boolean) =>
Effect.gen(function* () {
const events: SseEvent[] = []
while (true) {
const event = yield* readNextEvent(queue)
events.push(event)
if (predicate(event)) return events
}
}).pipe(
Effect.timeoutOrElse({
duration: "4 seconds",
orElse: () => Effect.fail(new Error("collectUntilEvent deadline exceeded")),
}),
)
@@ -1,25 +1,31 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { afterEach, test, expect, describe } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
import { ProviderID } from "../../src/provider/schema"
import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture"
import type { InstanceContext } from "../../src/project/instance-context"
import { Provider } from "@/provider/provider"
import { Env } from "../../src/env"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Env } from "../../src/env"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
const env = makeRuntime(Env.Service, Env.defaultLayer)
const originalEnv = new Map<string, string | undefined>()
const set = (k: string, v: string) =>
Effect.gen(function* () {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
process.env[k] = v
yield* Env.Service.use((svc) => svc.set(k, v))
})
function rememberEnv(k: string) {
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
}
const set = (ctx: InstanceContext, k: string, v: string) => {
rememberEnv(k)
process.env[k] = v
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
}
afterEach(async () => {
for (const [key, value] of originalEnv) {
@@ -30,250 +36,427 @@ afterEach(async () => {
await disposeAllInstances()
})
const list = Provider.Service.use((svc) => svc.list())
const withAuthJson = (contents: string) =>
Effect.acquireRelease(
Effect.promise(async () => {
const authPath = path.join(Global.Path.data, "auth.json")
let original: string | undefined
try {
original = await Filesystem.readText(authPath)
} catch {
original = undefined
}
await Filesystem.write(authPath, contents)
return { authPath, original }
}),
({ authPath, original }) =>
Effect.promise(async () => {
if (original !== undefined) {
await Filesystem.write(authPath, original)
return
}
await unlink(authPath).catch(() => undefined)
}),
async function list(ctx: InstanceContext) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}).pipe(Effect.provideService(InstanceRef, ctx)),
)
}
it.instance(
"Bedrock: config region takes precedence over AWS_REGION env var",
() =>
Effect.gen(function* () {
yield* set("AWS_REGION", "us-east-1")
yield* set("AWS_PROFILE", "default")
const providers = yield* list
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "eu-west-1",
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_REGION", "us-east-1")
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
}),
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
)
},
})
})
it.instance(
"Bedrock: falls back to AWS_REGION env var when no config region",
() =>
Effect.gen(function* () {
yield* set("AWS_REGION", "eu-west-1")
yield* set("AWS_PROFILE", "default")
const providers = yield* list
test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_REGION", "eu-west-1")
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
}),
)
},
})
})
it.instance(
"Bedrock: loads when bearer token from auth.json is present",
() =>
Effect.gen(function* () {
yield* withAuthJson(JSON.stringify({ "amazon-bedrock": { type: "api", key: "test-bearer-token" } }))
yield* set("AWS_PROFILE", "")
yield* set("AWS_ACCESS_KEY_ID", "")
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
}),
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
)
test("Bedrock: loads when bearer token from auth.json is present", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "eu-west-1",
},
},
},
}),
)
},
})
it.instance(
"Bedrock: config profile takes precedence over AWS_PROFILE env var",
() =>
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
yield* set("AWS_ACCESS_KEY_ID", "test-key-id")
const providers = yield* list
const authPath = path.join(Global.Path.data, "auth.json")
// Save original auth.json if it exists
let originalAuth: string | undefined
try {
originalAuth = await Filesystem.readText(authPath)
} catch {
// File doesn't exist, that's fine
}
try {
// Write test auth.json
await Filesystem.write(
authPath,
JSON.stringify({
"amazon-bedrock": {
type: "api",
key: "test-bearer-token",
},
}),
)
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "")
set(ctx, "AWS_ACCESS_KEY_ID", "")
set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
})
} finally {
// Restore original or delete
if (originalAuth !== undefined) {
await Filesystem.write(authPath, originalAuth)
} else {
try {
await unlink(authPath)
} catch {
// Ignore errors if file doesn't exist
}
}
}
})
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
profile: "my-custom-profile",
region: "us-east-1",
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
}),
{
config: {
provider: { "amazon-bedrock": { options: { profile: "my-custom-profile", region: "us-east-1" } } },
},
},
)
})
})
it.instance(
"Bedrock: includes custom endpoint in options when specified",
() =>
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
test("Bedrock: includes custom endpoint in options when specified", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
)
}),
{
config: {
provider: {
"amazon-bedrock": {
options: { endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" },
},
},
},
},
)
})
})
it.instance(
"Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present",
() =>
Effect.gen(function* () {
yield* set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
yield* set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
yield* set("AWS_PROFILE", "")
yield* set("AWS_ACCESS_KEY_ID", "")
const providers = yield* list
test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set(ctx, "AWS_PROFILE", "")
set(ctx, "AWS_ACCESS_KEY_ID", "")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
}),
{ config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } },
)
// Cross-region inference profile prefix handling.
// Models from models.dev may come with prefixes already (e.g. us., eu., global.).
// These should NOT be double-prefixed when passed to the SDK.
it.instance(
"Bedrock: model with us. prefix should not be double-prefixed",
() =>
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
provider: {
"amazon-bedrock": {
options: { region: "us-east-1" },
models: { "us.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (US)" } },
},
},
},
},
)
})
})
it.instance(
"Bedrock: model with global. prefix should not be prefixed",
() =>
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
// Tests for cross-region inference profile prefix handling
// Models from models.dev may come with prefixes already (e.g., us., eu., global.)
// These should NOT be double-prefixed when passed to the SDK
test("Bedrock: model with us. prefix should not be double-prefixed", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
models: {
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5 (US)",
},
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
test("Bedrock: model with global. prefix should not be prefixed", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
models: {
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5 (Global)",
},
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
provider: {
"amazon-bedrock": {
options: { region: "us-east-1" },
models: { "global.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (Global)" } },
},
},
},
},
)
})
})
it.instance(
"Bedrock: model with eu. prefix should not be double-prefixed",
() =>
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
test("Bedrock: model with eu. prefix should not be double-prefixed", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "eu-west-1",
},
models: {
"eu.anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5 (EU)",
},
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
provider: {
"amazon-bedrock": {
options: { region: "eu-west-1" },
models: { "eu.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (EU)" } },
},
},
},
},
)
})
})
it.instance(
"Bedrock: model without prefix in US region should get us. prefix added",
() =>
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
test("Bedrock: model without prefix in US region should get us. prefix added", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
models: {
"anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5",
},
},
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
set(ctx, "AWS_PROFILE", "default")
const providers = await list(ctx)
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
provider: {
"amazon-bedrock": {
options: { region: "us-east-1" },
models: { "anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5" } },
},
},
},
},
)
})
})
// Direct unit tests for cross-region inference profile prefix handling
// These test the prefix detection logic used in getModel
// Direct unit tests for cross-region inference profile prefix detection.
describe("Bedrock cross-region prefix detection", () => {
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
test("should detect global. prefix", () => {
expect(crossRegionPrefixes.some((p) => "global.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
const modelID = "global.anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect us. prefix", () => {
expect(crossRegionPrefixes.some((p) => "us.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
const modelID = "us.anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect eu. prefix", () => {
expect(crossRegionPrefixes.some((p) => "eu.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
const modelID = "eu.anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect jp. prefix", () => {
expect(crossRegionPrefixes.some((p) => "jp.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
const modelID = "jp.anthropic.claude-sonnet-4-20250514-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect apac. prefix", () => {
expect(crossRegionPrefixes.some((p) => "apac.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
const modelID = "apac.anthropic.claude-sonnet-4-20250514-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect au. prefix", () => {
expect(crossRegionPrefixes.some((p) => "au.anthropic.claude-sonnet-4-5-20250929-v1:0".startsWith(p))).toBe(true)
const modelID = "au.anthropic.claude-sonnet-4-5-20250929-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should NOT detect prefix for non-prefixed model", () => {
expect(crossRegionPrefixes.some((p) => "anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(false)
const modelID = "anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(false)
})
test("should NOT detect prefix for amazon nova models", () => {
expect(crossRegionPrefixes.some((p) => "amazon.nova-pro-v1:0".startsWith(p))).toBe(false)
const modelID = "amazon.nova-pro-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(false)
})
test("should NOT detect prefix for cohere models", () => {
expect(crossRegionPrefixes.some((p) => "cohere.command-r-plus-v1:0".startsWith(p))).toBe(false)
const modelID = "cohere.command-r-plus-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(false)
})
})
@@ -12,8 +12,8 @@ import { Provider } from "@/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { Env } from "../../src/env"
import { Effect, Layer, ManagedRuntime } from "effect"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Effect, Layer } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { testEffect } from "../lib/effect"
@@ -66,10 +66,8 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(RuntimeFlags.layer(flags)),
)
const providerRuntime = ManagedRuntime.make(Layer.mergeAll(Provider.defaultLayer, Plugin.defaultLayer), { memoMap })
async function run<A, E>(ctx: InstanceContext, fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
return providerRuntime.runPromise(
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* fn(provider)
@@ -2511,7 +2509,7 @@ test("plugin config providers persist after instance dispose", async () => {
const first = await withTestInstance({
directory: tmp.path,
fn: async (ctx) =>
providerRuntime.runPromise(
AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const provider = yield* Provider.Service
@@ -1,50 +1,53 @@
// Diagnostic suite for /event SSE delivery.
//
// Each test isolates ONE variable in the publisher chain while keeping the
// subscriber path constant (HttpClient against the in-process HttpApiApp
// routes, reading the SSE body via openInstanceEventStream). The pass/fail
// pattern across tests tells us where the bug lives:
// subscriber path constant (raw `app().request` reading the SSE body — no SDK
// consumer involvement). The pass/fail pattern across tests tells us where the
// bug lives:
//
// D1 (baseline): publish via Bus.Service.use — mirror of the existing
// httpapi-event.test.ts test 3. Confirms /event SSE delivery works for
// a SOME publish path.
// D1 (baseline): publish via Bus.Service.use via AppRuntime — mirror of the
// existing httpapi-event.test.ts test 3. Confirms /event SSE delivery
// works for a SOME publish path.
//
// D2: publish N times in quick succession via Bus.Service.use. If the bus
// subscription is acquired correctly there should be no message loss.
//
// D3: publish via SyncEvent.use.run — exercises the same path the HTTP
// handlers use (Session.updatePart → sync.run → bus.publish) without the
// HTTP roundtrip. Tells us whether the sync path itself can deliver
// in-process.
// D3: publish via SyncEvent.use.run via AppRuntime — exercises the same path
// the HTTP handlers use (Session.updatePart → sync.run → bus.publish)
// without the HTTP roundtrip. Tells us whether the sync path itself can
// deliver in-process.
//
// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus
// callback. Confirms pub/sub identity end-to-end without /event SSE.
// D4: publish via SyncEvent.use.run from a fresh `Effect.provide` scope
// (mimicking what happens if a handler's layer was scoped per-request).
//
// D5: in-process Bus.Service callback subscriber AND raw /event SSE subscriber
// receive the same publish. If both receive: no bug. If only the
// callback receives: the /event handler has an acquisition race.
//
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. Order-of-setup variable.
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { afterEach, describe, expect } from "bun:test"
import { Config, Deferred, Effect, Layer } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { Deferred, Effect, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { Bus } from "../../src/bus"
import { type AppServices, AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Server } from "../../src/server/server"
import { Event as ServerEvent } from "../../src/server/event"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SyncEvent } from "../../src/sync"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffectShared } from "../lib/effect"
import { collectUntilEvent, openInstanceEventStream, readNextEvent, type SseEvent } from "../lib/sse"
import { it } from "../lib/effect"
void Log.init({ print: false })
const EventData = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
type SseEvent = Schema.Schema.Type<typeof EventData>
type BusEvent = { type: string; properties: unknown }
afterEach(async () => {
@@ -52,39 +55,82 @@ afterEach(async () => {
await resetDatabase()
})
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
const inApp = <A, E>(eff: Effect.Effect<A, E, AppServices>) =>
Effect.flatMap(InstanceRef, (ctx) =>
ctx
? Effect.promise(() => AppRuntime.runPromise(eff.pipe(Effect.provideService(InstanceRef, ctx))))
: Effect.die("InstanceRef not provided in test scope"),
)
const it = testEffectShared(
Layer.mergeAll(
Bus.defaultLayer,
SyncEvent.defaultLayer,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
const publishConnected = Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {}))
const publishConnected = inApp(Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})))
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
return SyncEvent.use.run(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
})
return inApp(
SyncEvent.use.run(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
}),
)
}
const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
Effect.acquireRelease(Bus.Service.use((svc) => svc.subscribeAllCallback(handler)), (dispose) =>
Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) =>
Effect.sync(dispose),
)
const openEventStream = (directory: string) =>
Effect.gen(function* () {
const response = yield* Effect.promise(async () =>
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
)
if (!response.body) return yield* Effect.die("missing SSE response body")
const reader = response.body.getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
return reader
})
const decoder = new TextDecoder()
function decodeFrame(value: Uint8Array): SseEvent[] {
return decoder
.decode(value)
.split(/\n\n+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => Schema.decodeUnknownSync(EventData)(JSON.parse(part.replace(/^data: /, ""))))
}
const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
Effect.promise(() => reader.read()).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () => Effect.fail(new Error("timed out reading SSE chunk")),
}),
Effect.flatMap((result) => {
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
const frames = decodeFrame(result.value)
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
return Effect.succeed(frames[0]!)
}),
)
const collectUntilEvent = (reader: ReadableStreamDefaultReader<Uint8Array>, predicate: (event: SseEvent) => boolean) =>
Effect.gen(function* () {
const events: SseEvent[] = []
while (true) {
const event = yield* readNextEvent(reader)
events.push(event)
if (predicate(event)) return events
}
}).pipe(
Effect.timeoutOrElse({
duration: "4 seconds",
orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")),
}),
)
const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type
describe("/event SSE delivery diagnostics", () => {
@@ -96,7 +142,7 @@ describe("/event SSE delivery diagnostics", () => {
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openInstanceEventStream(directory)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
yield* publishConnected
@@ -111,7 +157,7 @@ describe("/event SSE delivery diagnostics", () => {
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openInstanceEventStream(directory)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const N = 5
@@ -126,13 +172,13 @@ describe("/event SSE delivery diagnostics", () => {
// The critical test. If D1 passes but this fails, the bus-identity fix is
// incomplete OR the sync.run publish path doesn't reach the same bus
// /event subscribes to, even when both share the memoMap.
// /event subscribes to, even within the same AppRuntime.
it.instance(
"D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openInstanceEventStream(directory)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
@@ -188,7 +234,7 @@ describe("/event SSE delivery diagnostics", () => {
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
})
const reader = yield* openInstanceEventStream(directory)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
@@ -210,6 +256,29 @@ describe("/event SSE delivery diagnostics", () => {
{ git: true, config: { formatter: false, lsp: false } },
)
// D7: like D5 but the "second subscriber" is a NO-OP AppRuntime.runPromise
// call (no PubSub.subscribe). If D7 passes, the specific subscribeAllCallback
// is what breaks SSE — not arbitrary AppRuntime usage. If D7 fails, anything
// running through AppRuntime concurrently with /event SSE breaks delivery.
it.instance(
"D7: SSE receives sync.run publish even with concurrent no-op AppRuntime activity",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
yield* inApp(Effect.void)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const collected = yield* collectUntilEvent(reader, isPartUpdated)
expect(collected.find(isPartUpdated)).toBeDefined()
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. If D5 fails and D6 passes, the order of
// subscriber setup is the determining factor.
@@ -218,7 +287,7 @@ describe("/event SSE delivery diagnostics", () => {
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openInstanceEventStream(directory)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const callbackReceived = yield* Deferred.make<BusEvent>()
@@ -1,95 +1,121 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { afterEach, describe, expect } from "bun:test"
import { Config, Effect, Layer, Queue } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import * as Log from "@opencode-ai/core/util/log"
import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Event as ServerEvent } from "../../src/server/event"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Event as ServerEvent } from "../../src/server/event"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffectShared } from "../lib/effect"
import { openInstanceEventStream, readNextEvent } from "../lib/sse"
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
function app() {
return Server.Default().app
}
const EventData = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>) {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error("timed out waiting for event")), 5_000)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
}
}
async function readFirstEvent(response: Response) {
if (!response.body) throw new Error("missing response body")
const reader = response.body.getReader()
try {
return await readEvent(reader)
} finally {
await reader.cancel()
}
}
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
const result = await readChunk(reader)
if (result.done || !result.value) throw new Error("event stream closed")
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")))
}
async function readStatusWithin(reader: ReadableStreamDefaultReader<Uint8Array>, delay: number) {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
reader.read().then((result) => (result.done ? "closed" : "event")),
new Promise<"open">((resolve) => {
timeout = setTimeout(() => resolve("open"), delay)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
}
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
const it = testEffectShared(
Layer.mergeAll(
Bus.defaultLayer,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
describe("event HttpApi", () => {
it.instance(
"serves event stream with correct headers and initial server.connected",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const response = yield* HttpClientRequest.get(EventPaths.event).pipe(
HttpClientRequest.setHeader("x-opencode-directory", directory),
HttpClient.execute,
)
test("serves event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
expect(response.status).toBe(200)
expect(response.headers["content-type"]).toContain("text/event-stream")
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
expect(response.headers["x-accel-buffering"]).toBe("no")
expect(response.headers["x-content-type-options"]).toBe("nosniff")
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/event-stream")
expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
expect(response.headers.get("x-accel-buffering")).toBe("no")
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
})
// Read one event to also verify the stream is wired up; the response is
// scope-bound so the connection closes when the test ends.
const events = yield* openInstanceEventStream(directory)
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
test("keeps the event stream open after the initial event", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
it.instance(
"keeps the event stream open after the initial event",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const events = yield* openInstanceEventStream(directory)
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
const reader = response.body.getReader()
try {
expect(await readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
expect(await readStatusWithin(reader, 250)).toBe("open")
} finally {
await reader.cancel()
}
})
// If no second event arrives within 250ms, the stream is still open.
const status = yield* Queue.take(events).pipe(
Effect.map(() => "event" as const),
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
)
expect(status).toBe("open")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
test("delivers instance bus events after the initial event", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
if (!response.body) throw new Error("missing response body")
it.instance(
"delivers instance bus events after the initial event",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const events = yield* openInstanceEventStream(directory)
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
const reader = response.body.getReader()
try {
expect(await readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
yield* Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {}))
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
const next = readEvent(reader)
const ctx = await reloadTestInstance({ directory: tmp.path })
await AppRuntime.runPromise(
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)),
)
expect(await next).toMatchObject({ type: "server.connected", properties: {} })
} finally {
await reader.cancel()
}
})
})
@@ -13,7 +13,7 @@ import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { LLMEvent, LLMResponse } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { RuntimeFlags } from "@/effect/runtime-flags"
import type { Agent } from "../../src/agent/agent"
import { LLM } from "../../src/session/llm"
@@ -137,7 +137,7 @@ async function loadFixture(providerID: string, modelID: string) {
function recordedNativeLLMLayer(spec: ProviderSpec) {
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const recordedClient = LLMClient.layer.pipe(
Layer.provide(Layer.mergeAll(RequestExecutor.layer, WebSocketExecutor.layer)),
Layer.provide(RequestExecutor.layer),
Layer.provide(
HttpRecorder.recordingLayer(spec.cassette, {
mode: shouldRecord ? "record" : "replay",
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { ToolFailure } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { jsonSchema, tool, type ModelMessage } from "ai"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { LLMNative } from "@/session/llm/native-request"
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
import type { Provider } from "@/provider/provider"
@@ -138,16 +138,16 @@ describe("session.llm-native.request", () => {
expect(request.model).toMatchObject({
id: "gpt-5-mini",
provider: "openai",
route: { id: "openai-responses" },
})
expect(request.model.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
expect(request.model.route.defaults.headers).toEqual({
"x-model": "model-header",
"x-request": "request-header",
})
expect(request.model.route.defaults.limits).toMatchObject({
context: 128_000,
output: 32_000,
route: "openai-responses",
baseURL: "https://api.openai.com/v1",
headers: {
"x-model": "model-header",
"x-request": "request-header",
},
limits: {
context: 128_000,
output: 32_000,
},
})
expect(request.system).toEqual([
{ type: "text", text: "agent system" },
@@ -211,50 +211,29 @@ describe("session.llm-native.request", () => {
])
})
test("selects native request routes for provider packages", () => {
const openai = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
apiKey: "test-key",
messages: [],
test("selects native routes from existing provider packages", () => {
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } }),
).toMatchObject({
route: "anthropic-messages",
baseURL: "https://api.anthropic.com/v1",
})
expect(openai.route.id).toBe("openai-responses")
expect(openai.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
const anthropic = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } },
apiKey: "test-key",
messages: [],
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({
route: "gemini",
baseURL: "https://generativelanguage.googleapis.com/v1beta",
})
expect(anthropic.route.id).toBe("anthropic-messages")
expect(anthropic.route.endpoint.baseURL).toBe("https://api.anthropic.com/v1")
const google = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } },
apiKey: "test-key",
messages: [],
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }),
).toMatchObject({
route: "openai-compatible-chat",
baseURL: "https://api.openai.com/v1",
})
expect(google.route.id).toBe("gemini")
expect(google.route.endpoint.baseURL).toBe("https://generativelanguage.googleapis.com/v1beta")
const compatible = LLMNative.model({
model: {
...baseModel,
providerID: ProviderID.make("opencode"),
api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
},
apiKey: "test-key",
messages: [],
expect(
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }),
).toMatchObject({
route: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
})
expect(compatible.route.id).toBe("openai-compatible-chat")
expect(compatible.route.endpoint.baseURL).toBe("https://ai.example.test/v1")
const openrouter = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } },
apiKey: "test-key",
messages: [],
})
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
})
test("fails fast for unsupported provider packages", () => {
@@ -281,20 +260,6 @@ describe("session.llm-native.request", () => {
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: {
...baseModel,
providerID: ProviderID.make("opencode"),
api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" },
},
provider: { ...providerInfo, id: ProviderID.make("opencode") },
auth: undefined,
}),
).toMatchObject({
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("google") },
@@ -316,7 +281,7 @@ describe("session.llm-native.request", () => {
provider: providerInfo,
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" })
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI or Anthropic" })
expect(
LLMNativeRuntime.status({
@@ -417,16 +382,12 @@ describe("session.llm-native.request", () => {
LLMClient.prepare(
LLMNative.request({
model: baseModel,
apiKey: "test-openai-key",
messages: [{ role: "user", content: "hello" }],
providerOptions: { openai: { store: false } },
maxOutputTokens: 512,
headers: { "x-request": "request-header" },
}),
).pipe(
Effect.provide(LLMClient.layer),
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
),
).pipe(Effect.provide(LLMClient.layer), Effect.provide(RequestExecutor.defaultLayer)),
)
expect(prepared).toMatchObject({
+48 -41
View File
@@ -1,15 +1,14 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Layer, ManagedRuntime, Stream } from "effect"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
import { InstanceRef } from "../../src/effect/instance-ref"
import { LLM } from "../../src/session/llm"
import type { InstanceContext } from "../../src/project/instance-context"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
@@ -22,6 +21,7 @@ import { tmpdir, withTestInstance } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Permission } from "@/permission"
import { LLMAISDK } from "@/session/llm/ai-sdk"
@@ -50,14 +50,12 @@ const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: stri
}
}
const providerRuntime = ManagedRuntime.make(Provider.defaultLayer, { memoMap })
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
const effect = Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
})
return providerRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
}
const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
@@ -84,7 +82,7 @@ function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>, fl
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(executor, WebSocketExecutor.layer)))),
Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))),
Layer.provide(RuntimeFlags.layer(flags)),
)
}
@@ -1977,45 +1975,54 @@ describe("session.llm.stream", () => {
const body = capture.body
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
const messages = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
expect(messages[0]?.role).toBe("user")
expect(messages[0]?.content[0]).toMatchObject({
type: "text",
text: "Can you check whether there are any PDF files in my home directory?",
})
expect(messages.some((message) => message.content.some((part) => "cache_control" in part))).toBe(true)
const toolUseIndex = messages.findIndex((message) => message.content.some((part) => part.type === "tool_use"))
expect(toolUseIndex).toBeGreaterThan(0)
expect(messages[toolUseIndex].role).toBe("assistant")
expect(messages[toolUseIndex].content.filter((part) => part.type === "tool_use")).toMatchObject([
expect(body.messages).toStrictEqual([
{
type: "tool_use",
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
name: "read",
input: { filePath: "/root" },
role: "user",
content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
},
{
type: "tool_use",
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
name: "glob",
input: { pattern: "**/*.pdf", path: "/root" },
role: "assistant",
content: [
{
type: "text",
text: "I checked your home directory and looked for PDF files.",
},
{
type: "tool_use",
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
name: "read",
input: { filePath: "/root" },
},
{
type: "tool_use",
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
name: "glob",
input: { pattern: "**/*.pdf", path: "/root" },
cache_control: {
type: "ephemeral",
},
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
content: "<path>/root</path>",
},
{
type: "tool_result",
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
content: "No files found",
cache_control: {
type: "ephemeral",
},
},
],
},
])
expect(messages[toolUseIndex + 1]).toMatchObject({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
content: "<path>/root</path>",
},
{
type: "tool_result",
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
content: "No files found",
},
],
})
},
})
})
@@ -36,6 +36,7 @@ import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionV2 } from "../../src/v2/session"
import { SessionStorageSql } from "../../src/v2/storage/session-sql"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell"
@@ -507,6 +508,7 @@ noLLMServer.instance(
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
Effect.provide(SessionV2.layer),
Effect.provide(SessionStorageSql.defaultLayer),
)
const row = Database.use((db) =>
db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),

Some files were not shown because too many files have changed in this diff Show More