mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-21 20:50:09 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c633d10e74 | |||
| 178840489f | |||
| 71423b9a58 | |||
| b3d6f93148 | |||
| dcbd244dd7 | |||
| dc55ece384 | |||
| 8b38c9b999 | |||
| 2743504e60 | |||
| 479b49ffa6 | |||
| 110d4091e5 | |||
| e674c242e1 |
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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?
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}) {}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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") })
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
@@ -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)", () =>
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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.",
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user