Compare commits

...

75 Commits

Author SHA1 Message Date
Aiden Cline 6ea55e63ad refactor(ai): align multimodal naming 2026-08-01 11:35:11 -05:00
Aiden Cline 003b22edda fix: preserve provider error status (#39976) 2026-08-01 11:06:00 -05:00
Kit Langton d5f6c088f0 fix(tui): prevent sidebar scrollbar flash (#40056) 2026-08-01 15:12:37 +00:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
opencode-agent[bot] 85ea15e56d test(tui): wait for mini prompt readiness (#39980)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-01 01:05:18 +00:00
Kit Langton 6a543791b6 fix(tui): persist tab reorder once per drag (#39942) 2026-07-31 20:36:00 -04:00
Kit Langton 47c6840752 fix(tui): harden session tab state hygiene (#39941) 2026-07-31 20:35:07 -04:00
Kit Langton 14e4c3dfd7 fix(tui): ignore hidden tab close hitbox (#39940) 2026-07-31 20:34:13 -04:00
opencode-agent[bot] ee6577936d refactor(tui): remove unused duration formatter (#39964)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:46 -04:00
opencode-agent[bot] 8285a18f18 refactor(tui): remove unused revert diff parser (#39963)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:35 -04:00
opencode-agent[bot] cfcd8de62a refactor(cli): remove unused warning helper (#39962)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:32:21 -04:00
opencode-agent[bot] c2db18c59b refactor(tui): remove unwired file selection helper (#39961)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:50 -04:00
opencode-agent[bot] 70393a1d18 Remove unused error data serializer (#39960)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:22 -04:00
opencode-agent[bot] eab59ea021 Remove unused locale helpers (#39959)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:31:06 -04:00
opencode-agent[bot] 4569659b03 Remove unused Zed helpers (#39958)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:53 -04:00
opencode-agent[bot] b5afaed053 Remove unused config optional hook (#39957)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:29 -04:00
opencode-agent[bot] 97f5fb3399 Remove unused subagent retry formatter (#39952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:20 -04:00
opencode-agent[bot] 18843682ad refactor(tui): remove ignored attention kv option (#39956)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:30:09 -04:00
opencode-agent[bot] 9eb9c1db7d refactor(tui): remove placeholder LSP panel (#39955)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:54 -04:00
opencode-agent[bot] 8846336e11 refactor(tui): remove unused fade file path (#39954)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:34 -04:00
opencode-agent[bot] f6b5f6b6b3 refactor(tui): remove prompt re-export barrels (#39953)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:29:27 -04:00
opencode-agent[bot] 02df1e9737 refactor(tui): reuse canonical logo (#39951)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:55 -04:00
opencode-agent[bot] 5b84e39f6d refactor(tui): remove stub child cycling commands (#39949)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:23 -04:00
opencode-agent[bot] 6d3f84b4ff refactor(tui): remove dialog show helpers (#39948)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:28:02 -04:00
opencode-agent[bot] 255e9bbf06 refactor(tui): remove onboarding hint residue (#39947)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:52 -04:00
opencode-agent[bot] 25b6a179e2 refactor(cli): remove no-op migrate command (#39946)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:29 -04:00
opencode-agent[bot] 80874c241f refactor(tui): remove dead assistant renderer (#39945)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:27:15 -04:00
opencode-agent[bot] f99469bdf8 refactor(core): remove unused layer exports (#39975)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:38 -04:00
opencode-agent[bot] fe0eb4ea8f refactor(core): remove orphaned move service (#39974)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:21 -04:00
opencode-agent[bot] a484f87680 refactor(core): remove unused dependencies (#39973)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:26:06 -04:00
opencode-agent[bot] 3b6ab392ac refactor(core): remove unused console state model (#39972)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:45 -04:00
opencode-agent[bot] 6c567dc745 refactor(core): remove unreferenced layer map example (#39971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:27 -04:00
opencode-agent[bot] 92567700f6 refactor(tui): remove unused stop voice operation (#39969)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-31 20:25:17 -04:00
James Long 90c84639e3 feat(theme): export expandTheme (#39967) 2026-07-31 18:32:55 -04:00
opencode-agent[bot] 56c6add5c3 refactor(core): remove unused helpers (#39943)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 16:17:14 -05:00
Kit Langton 2cfe8883ea feat(plugin): expose session rename and wait to plugins (#39932) 2026-07-31 21:08:33 +00:00
Kit Langton 102086c50f feat(tui): hot-reload local TUI plugins (#39776) 2026-07-31 16:35:05 -04:00
Kit Langton 06e26d89ad feat(protocol): accept title on session create (#39935) 2026-07-31 19:39:54 +00:00
Aiden Cline 84dd56ed34 fix(core): bound outbound image history (#39929) 2026-07-31 13:57:04 -05:00
opencode-agent[bot] f92e490bb6 fix(tui): preserve diff hunk boundaries (#39693)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-31 14:47:36 -04:00
Kit Langton 0481dba88e fix(tui): improve narrow layouts (#39918) 2026-07-31 14:44:13 -04:00
Kit Langton dd0e41c633 fix(tui): render plugin slots and routes with component semantics (#39917) 2026-07-31 14:30:57 -04:00
opencode-agent[bot] b69e51d835 refactor(ai): simplify provider options (#39924)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-31 13:27:32 -05:00
opencode-agent[bot] e60e8d9387 refactor(core): rename attachments config to media (#39927)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-31 13:26:53 -05:00
Kit Langton 3bfce3fd2d feat(tui): expand pasted text (#39920) 2026-07-31 18:17:35 +00:00
Aiden Cline b498a5c6c4 feat(ai): expand OpenRouter native support (#39907) 2026-07-31 12:57:18 -05:00
Kit Langton db9e942398 fix(core): resize large image attachments (#39919) 2026-07-31 13:24:05 -04:00
Kit Langton 0e26116f68 fix(tui): stabilize open picker 2026-07-31 16:38:48 +00:00
Aiden Cline 3468aa0140 feat(ai): configure chat max tokens field (#39909) 2026-07-31 11:14:08 -05:00
Kit Langton 7757f712a2 test(app): restore stable offset observer ordering (#39901) 2026-07-31 12:13:16 -04:00
Aiden Cline 7129ed6e62 fix(core): respect model input limits (#39797) 2026-07-31 10:41:36 -05:00
Kit Langton 6c37842520 fix(cli): deduplicate Node asset destinations (#39900) 2026-07-31 11:40:38 -04:00
Kit Langton dc3c996892 fix(tui): stabilize generated session titles (#39894) 2026-07-31 11:13:25 -04:00
Kit Langton 7fd12c560c fix(tui): fit project paths in open menu (#39887) 2026-07-31 11:07:12 -04:00
Kit Langton 7aaf4e7750 refactor(session): centralize fallback title policy (#39890) 2026-07-31 10:56:38 -04:00
Kit Langton 1311d909a7 fix(tui): preserve session location during handoff (#39886) 2026-07-31 10:54:36 -04:00
Kit Langton d07d9ae0da fix(tui): remove shells from their location (#39885) 2026-07-31 10:46:43 -04:00
Kit Langton 1d18459cdc fix(tui): align session picker scope (#39784) 2026-07-31 10:32:16 -04:00
Kit Langton db45026c6c fix(tui): default tabs to global scope (#39783) 2026-07-31 10:15:53 -04:00
Kit Langton 77df98db51 Revert "feat(tui): delete current session (#39750)"
This reverts commit 7814568ba0.
2026-07-31 10:01:35 -04:00
Aiden Cline ff2b184af1 feat(ai): support Gemini thinking levels (#39796) 2026-07-30 23:38:00 -05:00
Aiden Cline 671e164f8d refactor(core): contain Codex in OpenAI plugin (#39734) 2026-07-30 21:43:38 -05:00
Aiden Cline 5b4ebf3e9d fix(core): map xAI native options (#39787) 2026-07-30 21:42:53 -05:00
Aiden Cline 98229d466d feat(plugin): add session request hook (#39764) 2026-07-30 21:11:22 -05:00
Kit Langton 5c7e2b5042 fix(tui): clarify open menu project labels (#39780) 2026-07-30 21:34:29 -04:00
Kit Langton 0a6a5d3e80 fix(session): retry failed title generation (#39748) 2026-07-30 21:26:44 -04:00
Kit Langton eb95bd27fe feat(session): make generated titles optional (#39747) 2026-07-30 21:24:34 -04:00
Kit Langton 865f512a44 fix(tui): preserve current selection across list updates (#39774) 2026-07-31 00:48:51 +00:00
Kit Langton a460f02f67 feat(tui): inherit session directory when creating a new session (#39753) 2026-07-30 20:31:38 -04:00
Kit Langton 146fdb9de1 feat(tui): add open menu for sessions and projects (#39752) 2026-07-30 20:08:12 -04:00
Kit Langton b866900417 fix(tui): name deleted session in toast (#39768) 2026-07-30 19:59:27 -04:00
Kit Langton 8e3e94aa26 fix(tui): focus palette settings after layout (#39585) 2026-07-30 19:39:16 -04:00
Aiden Cline cc1289048e refactor(core): isolate AI SDK native mappings (#39761) 2026-07-30 18:38:29 -05:00
Kit Langton 7814568ba0 feat(tui): delete current session (#39750) 2026-07-30 17:06:41 -04:00
Kit Langton 16b247f756 fix(tui): smooth new session tab handoff (#39745) 2026-07-30 16:17:01 -04:00
269 changed files with 5164 additions and 3462 deletions
+2 -4
View File
@@ -67,6 +67,7 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -87,7 +88,6 @@
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -383,7 +383,6 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -404,7 +403,6 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -426,7 +424,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
@@ -520,6 +517,7 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
+7 -5
View File
@@ -10,7 +10,9 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `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, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
## Tests
@@ -74,7 +76,7 @@ export const route = Route.make({
})
```
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.
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` 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 `AIError`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.
@@ -126,7 +128,7 @@ const selected = model("gpt-5", {
})
```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`.
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
@@ -136,10 +138,10 @@ Do not expose `Route` in provider package settings. Route composition stays an i
packages/ai/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, Limits, LanguageModel, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, LLMError, ToolFailure
errors.ts error reasons, AIError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
+6 -6
View File
@@ -96,7 +96,7 @@ contains identity, capabilities, pricing metadata, provider-specific option
types, reusable request-behavior defaults, and hidden execution behavior.
Normal users do not need to learn the current `Route` composite. Protocol,
endpoint, auth, transport, and hooks are bound behind `Model`.
endpoint, auth, transport, and hooks are bound behind `LanguageModel`.
### Request
@@ -539,7 +539,7 @@ Hosted tools do not pretend to have local handlers, and callers do not inspect a
### Run stream
`LLM.stream` returns an Effect `Stream<RunEvent, LLMError, Requirements>`.
`LLM.stream` returns an Effect `Stream<RunEvent, AIError, Requirements>`.
Run events explicitly expose orchestration boundaries:
```ts
@@ -828,11 +828,11 @@ portable semantic guarantee.
## Error Model
The Effect error channel is a tagged domain union rather than one `LLMError`
The Effect error channel is a tagged domain union rather than one `AIError`
wrapper with nested reasons. Illustrative categories:
```ts
type LLMError =
type AIError =
| AuthenticationError
| InvalidRequestError
| UnsupportedCapabilityError
@@ -1079,7 +1079,7 @@ The redesign intentionally removes or changes these current concepts:
| `LLM.generate` means one turn | `LLM.generate` means complete run |
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
| Public `Route` mental model | Hidden behind executable `Model` |
| Public `Route` mental model | Hidden behind executable `LanguageModel` |
| `Provider.make` structural helper | Experimental declarative `Provider.define` |
| Schema classes as canonical values | Plain immutable values plus schema subpath |
| `LLM.updateRequest` | Object spread |
@@ -1089,7 +1089,7 @@ The redesign intentionally removes or changes these current concepts:
| `generateObject` | Typed `output` option on `generate` |
| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions |
| `providerExecuted` dispatch check | Distinct hosted-tool constructors |
| One wrapped `LLMError` | Tagged domain error union |
| One wrapped `AIError` | Tagged domain error union |
OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable
prompt admission, persistence, permission, tool settlement, and continuation
+1 -1
View File
@@ -195,7 +195,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`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.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
+10 -10
View File
@@ -33,7 +33,7 @@ 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,
- Runtime capability is a `LanguageModel` 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
@@ -137,7 +137,7 @@ starts hiding the real provider-specific config.
- accepts model id only
- returns executable models
- does not accept endpoint/auth/deployment overrides
4. **Model**
4. **Language Model**
- model id
- route value
- provider id
@@ -164,7 +164,7 @@ execution mechanism:
```ts
type ProviderFacade<APIs, Config> = {
readonly id: ProviderID
readonly model: (id: string) => Model
readonly model: (id: string) => LanguageModel
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs
```
@@ -181,8 +181,8 @@ export const OpenAI = {
configure: configureOpenAI,
} satisfies ProviderFacade<
{
responses: (id: string) => Model
chat: (id: string) => Model
responses: (id: string) => LanguageModel
chat: (id: string) => LanguageModel
},
OpenAIConfig
>
@@ -528,7 +528,7 @@ The chosen split is:
```txt
Route = execution mechanics
Provider facade = configured route group
Model = selected executable model carrying route value
LanguageModel = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call
```
@@ -549,13 +549,13 @@ App boundary = explicit durable-config -> typed-provider call
entrypoint maps its scoped `transport` setting before constructing the model.
- 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
- No executable `ModelRef`. The executable handle is `LanguageModel`; 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.
- [x] Replace the current executable `ModelRef` with `LanguageModel`.
- [x] Change `LanguageModel.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.
@@ -566,7 +566,7 @@ App boundary = explicit durable-config -> typed-provider call
- [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
- [x] Remove request-shaping defaults from `LanguageModel`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
+2 -1
View File
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -133,6 +133,7 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
+4 -4
View File
@@ -1,25 +1,25 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
import type { LLMError } from "./schema"
import type { AIError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
) => Effect.Effect<ImageResponse, LLMError>
) => Effect.Effect<ImageResponse, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, LLMError> =>
): Effect.Effect<ImageResponse, AIError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, LLMError>
}) as Effect.Effect<ImageResponse, AIError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
+5 -8
View File
@@ -1,13 +1,10 @@
import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { HttpOptions, InvalidRequestReason, AIError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
readonly id: string
readonly generate: (
request: ImageRequestFor<Options>,
execute: ImageExecute,
) => Effect.Effect<ImageResponse, LLMError>
readonly generate: (request: ImageRequestFor<Options>, execute: ImageExecute) => Effect.Effect<ImageResponse, AIError>
}
export type ImageOptions = Record<string, unknown>
@@ -146,13 +143,13 @@ export function request(input: ImageRequest | ImageRequestInput) {
export function generate<const Model extends object>(
input: ImageRequestInput<Model>,
): Effect.Effect<ImageResponse, LLMError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput) {
return Effect.try({
try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) =>
new LLMError({
new AIError({
module: "Image",
method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
+4 -4
View File
@@ -5,8 +5,8 @@ export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type {
RouteModelInput,
RouteRoutedModelInput,
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
Interface as LLMClientShape,
Service as LLMClientService,
} from "./route/client"
@@ -33,7 +33,7 @@ export type {
export * as LLM from "./llm"
export type {
Definition as ProviderDefinition,
ModelFactory as ProviderModelFactory,
ModelOptions as ProviderModelOptions,
LanguageModelFactory as ProviderLanguageModelFactory,
LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider"
export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package"
+28 -21
View File
@@ -4,33 +4,33 @@ import {
GenerationOptions,
HttpOptions,
InvalidProviderOutputReason,
LLMError,
AIError,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
Model,
LanguageModel,
SystemPart,
ToolChoice,
ToolDefinition,
type ContentPart,
type ModelProviderOptions,
type LanguageModelProviderOptions,
} from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput<SelectedModel extends Model = Model> = Omit<
export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
ConstructorParameters<typeof LLMRequest>[0],
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & {
readonly model: SelectedModel
readonly model: SelectedLanguageModel
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>>
readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
readonly http?: HttpOptions.Input
}
@@ -38,7 +38,9 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => {
export const request = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
) => {
const {
system: requestSystem,
prompt,
@@ -66,7 +68,10 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice">
type GenerateObjectBase<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
RequestInput<SelectedLanguageModel>,
"tools" | "toolChoice"
>
export class GenerateObjectResponse<T> {
constructor(
@@ -83,13 +88,15 @@ export class GenerateObjectResponse<T> {
}
}
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
export interface GenerateObjectOptions<
S extends ToolSchema<any>,
SelectedLanguageModel extends LanguageModel = LanguageModel,
> extends GenerateObjectBase<SelectedLanguageModel> {
readonly schema: S
}
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel = LanguageModel>
extends GenerateObjectBase<SelectedLanguageModel> {
/** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema
}
@@ -108,7 +115,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
)
if (!call || !LLMEvent.is.toolCall(call))
return yield* new LLMError({
return yield* new AIError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
@@ -118,7 +125,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
const object = yield* tool._decode(call.input).pipe(
Effect.mapError(
(error) =>
new LLMError({
new AIError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
@@ -138,16 +145,16 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
* Two input modes:
*
* 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`.
* Decode failures surface as `LLMError`.
* Decode failures surface as `AIError`.
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
* the schema is only available at runtime (MCP, plugin manifests). Caller validates.
*/
export function generateObject<const SelectedModel extends Model, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError>
export function generateObject<const SelectedModel extends Model>(
options: GenerateObjectDynamicOptions<SelectedModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError>
export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
const { schema, ...rest } = options
+11 -13
View File
@@ -6,7 +6,7 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
LLMError,
AIError,
LLMEvent,
mergeJsonRecords,
Usage,
@@ -242,16 +242,14 @@ const AnthropicUsage = Schema.StructWithRest(
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
server_tool_use: optionalNull(
Schema.StructWithRest(
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.StructWithRest(Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
),
output_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.StructWithRest(Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
@@ -725,8 +723,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic:
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
},
})
}
@@ -816,7 +813,8 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const providerMetadata =
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [
{
@@ -980,7 +978,7 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
}
const onError = (event: AnthropicEvent) =>
new LLMError({
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
@@ -3,7 +3,7 @@ import { Route } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol"
import {
LLMError,
AIError,
LLMEvent,
Usage,
type CacheHint,
@@ -11,7 +11,7 @@ import {
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type ModelToolSchemaCompatibility,
type LanguageModelToolSchemaCompatibility,
type ProviderMetadata,
type ReasoningPart,
type ToolCallPart,
@@ -232,7 +232,7 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo
})
const lowerTools = (
compatibility: ModelToolSchemaCompatibility | undefined,
compatibility: LanguageModelToolSchemaCompatibility | undefined,
breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => {
@@ -274,9 +274,7 @@ const reasoningSignature = (part: ReasoningPart) => {
const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string"
? bedrock.redactedData
: undefined
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@@ -656,7 +654,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
return yield* new LLMError({
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
+58 -4
View File
@@ -29,9 +29,30 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
@@ -111,6 +132,12 @@ const GeminiToolConfig = Schema.Struct({
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
})
const GeminiGenerationConfig = Schema.Struct({
@@ -123,7 +150,10 @@ const GeminiGenerationConfig = Schema.Struct({
})
const GeminiBodyFields = {
cachedContent: Schema.optional(Schema.String),
contents: Schema.Array(GeminiContent),
safetySettings: optionalArray(GeminiSafetySetting),
serviceTier: Schema.optional(Schema.String),
systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig),
@@ -316,17 +346,38 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
const resolveOptions = (request: LLMRequest) => {
const value = request.providerOptions?.gemini?.thinkingConfig
if (!ProviderShared.isRecord(value)) return {}
const input = request.providerOptions?.gemini
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0
const generation = request.generation
@@ -342,7 +393,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
}
return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools
+3 -3
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
LLMError,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
@@ -125,7 +125,7 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
}
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new LLMError({
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
@@ -285,7 +285,7 @@ export const model = (input: ModelInput) => {
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
}
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, AIError> => {
if (image.type === "bytes")
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
+4 -10
View File
@@ -3,7 +3,7 @@ import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMError,
AIError,
LLMEvent,
Usage,
type FinishReason,
@@ -434,10 +434,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase =
ProviderShared.isRecord(metadata)
? messagePhase(metadata.phase, extension)
: undefined
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
@@ -646,10 +643,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
events,
]
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -975,7 +969,7 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
const providerError = (state: ParserState, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
return new LLMError({
return new AIError({
module: state.id,
method: "stream",
reason: classifyProviderFailure({ message, code }),
+128 -29
View File
@@ -6,11 +6,12 @@ import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMError,
AIError,
LLMEvent,
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type JsonSchema,
type LLMRequest,
type MediaPart,
@@ -38,6 +39,11 @@ export const PATH = "/chat/completions"
// The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport.
const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
@@ -47,6 +53,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -61,7 +68,11 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
@@ -69,7 +80,10 @@ const OpenAIChatUserContent = Schema.Union([
])
const OpenAIChatMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -83,10 +97,16 @@ const OpenAIChatMessage = Schema.Union([
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -107,6 +127,7 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -209,13 +230,20 @@ export interface ParserState {
// Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -257,11 +285,14 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text })
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
continue
}
if (part.type === "media") {
@@ -270,14 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
}
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
}
if (content.every((part) => part.type === "text"))
return { role: "user" as const, content: content.map((part) => part.text).join("") }
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
return {
role: "user" as const,
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
}
return { role: "user" as const, content }
})
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -315,29 +350,44 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
messages.push({
role: "tool",
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
messages.push({
role: "tool",
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
const files = content.filter((item) => item.type === "file")
images.push(
...(yield* Effect.forEach(files, (item) =>
@@ -351,15 +401,29 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message)).messages
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
})
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
request.system.length === 0
? []
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
? [
{
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
cache_control: options.cacheControl?.(part.cache),
})),
},
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
@@ -370,28 +434,53 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
messages.push({
role: "user",
content: [
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
continue
}
const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
messages[messages.length - 1] = options.cacheControl?.(part.cache)
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
else if (previous?.role === "user" && Array.isArray(previous.content))
messages[messages.length - 1] = {
role: "user",
content: [...previous.content, { type: "text", text: part.text }],
content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
}
else messages.push({ role: "user", content: part.text })
else
messages.push(
options.cacheControl?.(part.cache)
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
}
: { role: "user", content: part.text },
)
continue
}
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message)
const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
}
flushImages()
return messages
@@ -405,7 +494,10 @@ const lowerOptions = (request: LLMRequest) => {
}
}
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request: LLMRequest,
options: LoweringOptions = {},
) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField
@@ -415,19 +507,26 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return {
model: request.model.id,
messages: yield* lowerMessages(request),
messages: yield* lowerMessages(request, options),
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
max_tokens: generation?.maxTokens,
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
temperature: generation?.temperature,
top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty,
@@ -456,7 +555,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
// through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
@@ -546,7 +645,7 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
if (event.error)
return yield* new LLMError({
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
@@ -1,11 +1,11 @@
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat"
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
export type OpenAICompatibleChatLanguageModelInput = RouteRoutedLanguageModelInput
/**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
@@ -1,10 +1,10 @@
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { OpenResponses } from "./open-responses"
const ADAPTER = "openai-compatible-responses"
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
export type OpenAICompatibleResponsesLanguageModelInput = RouteRoutedLanguageModelInput
/**
* Deployment adapter for providers that expose an Open Responses-compatible
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
LLMError,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
@@ -85,7 +85,7 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
}
const invalidOutput = (message: string) =>
new LLMError({
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+7 -7
View File
@@ -6,7 +6,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
AIError,
type ContentPart,
type LLMRequest,
type MediaPart,
@@ -41,7 +41,7 @@ export interface ToolAccumulator {
* when at least one is defined. Returns `undefined` when neither input nor
* output is known so routes don't publish a misleading `0`.
*
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
* Under the additive `AI.Usage` contract, `inputTokens` and `outputTokens`
* are the non-cached input and visible output only. The provider-supplied
* `total` is the source of truth when present; the computed fallback
* under-counts cache and reasoning by design and exists mainly so
@@ -88,7 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
}
export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({
new AIError({
module: "ProviderShared",
method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }),
@@ -238,9 +238,9 @@ export const errorText = (error: unknown) => {
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries) so the public error channel stays
* `LLMError`.
* `AIError`.
*/
export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> =>
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
@@ -257,7 +257,7 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
* lands here.
*/
export const invalidRequest = (message: string) =>
new LLMError({
new AIError({
module: "ProviderShared",
method: "request",
reason: new InvalidRequestReason({ message }),
@@ -304,7 +304,7 @@ export const unsupportedContent = (
* Build a `validate` step from a Schema decoder. Replaces the per-route
* lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
* invalid(e.message)))`. Any decode error is translated into
* `LLMError` carrying the original parse-error message.
* `AIError` carrying the original parse-error message.
*/
export const validateWith =
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
@@ -1,9 +1,9 @@
import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image"
import { InvalidRequestReason, LLMError } from "../../schema"
import { InvalidRequestReason, AIError } from "../../schema"
const invalid = (module: string, message: string) =>
new LLMError({
new AIError({
module,
method: "generate",
reason: new InvalidRequestReason({ message }),
@@ -15,7 +15,7 @@ export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>)
export const decodeDataUrl = (
url: string,
module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.succeed(undefined)
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
@@ -1,4 +1,4 @@
import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema"
import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../schema"
import { isRecord } from "../../utils/record"
import { GeminiToolSchema } from "./gemini-tool-schema"
@@ -69,7 +69,7 @@ const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(sche
const modelCompatibility = (
schema: JsonSchema,
compatibility: ModelToolSchemaCompatibility | undefined,
compatibility: LanguageModelToolSchemaCompatibility | undefined,
): JsonSchema => {
if (compatibility === undefined) return schema
switch (compatibility) {
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -112,8 +112,8 @@ const appendTool = <K extends StreamKey>(
}
}
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
result instanceof LLMError
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | AIError): result is AIError =>
result instanceof AIError
/**
* Register a tool call whose start event arrived before any argument deltas.
@@ -138,7 +138,7 @@ export const appendOrStart = <K extends StreamKey>(
key: K,
delta: { readonly id?: string; readonly name?: string; readonly text: string },
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
): AppendOutcome<K> | AIError => {
const current = tools[key]
const id = current?.id ?? delta.id
const name = current?.name ?? delta.name
@@ -167,7 +167,7 @@ export const appendExisting = <K extends StreamKey>(
key: K,
text: string,
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
): AppendOutcome<K> | AIError => {
const current = tools[key]
if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current, events: [] }
+2 -2
View File
@@ -4,7 +4,7 @@ import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type I
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
LLMError,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
@@ -95,7 +95,7 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
}
const invalidOutput = (message: string) =>
new LLMError({
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+2 -2
View File
@@ -2,7 +2,7 @@ import { Effect, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
import { InvalidProviderOutputReason, AIError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input"
@@ -58,7 +58,7 @@ const nativeOptions = (options: ZAIImageOptions | undefined) => {
}
const invalidOutput = (message: string) =>
new LLMError({
new AIError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+11 -12
View File
@@ -3,7 +3,7 @@ import {
AuthenticationReason,
ContentPolicyReason,
InvalidRequestReason,
LLMError,
AIError,
ProviderErrorEvent,
ProviderInternalReason,
QuotaExceededReason,
@@ -16,7 +16,6 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -33,7 +32,6 @@ const patterns = [
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
@@ -44,14 +42,18 @@ const patterns = [
/token limit exceeded/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
failure instanceof AIError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
@@ -84,7 +86,7 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common)
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
+4 -2
View File
@@ -1,10 +1,12 @@
import type { Model, ProviderOptions } from "./schema"
import type { LanguageModel, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
@@ -13,7 +15,7 @@ export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options>
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
}
export * as ProviderPackage from "./provider-package"
+9 -9
View File
@@ -1,6 +1,6 @@
import type { Model, ModelID, ProviderID } from "./schema"
import type { LanguageModel, ModelID, ProviderID } from "./schema"
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
export type LanguageModelOptions = Pick<LanguageModel.Input, "defaults" | "compatibility">
/**
* Advanced structural provider definition helper. Built-in providers should
@@ -8,23 +8,23 @@ export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
* 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> = (
export type LanguageModelFactory<Options extends LanguageModelOptions = LanguageModelOptions> = (
id: string | ModelID,
options?: Options,
) => Model
) => LanguageModel
type AnyModelFactory = (...args: never[]) => Model
type AnyLanguageModelFactory = (...args: never[]) => LanguageModel
export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
export interface Definition<Factory extends AnyLanguageModelFactory = LanguageModelFactory> {
readonly id: ProviderID
readonly model: Factory
readonly apis?: Record<string, AnyModelFactory>
readonly apis?: Record<string, AnyLanguageModelFactory>
}
type DefinitionShape = {
readonly id: ProviderID
readonly model: (...args: never[]) => Model
readonly apis?: Record<string, (...args: never[]) => Model>
readonly model: (...args: never[]) => LanguageModel
readonly apis?: Record<string, (...args: never[]) => LanguageModel>
}
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
+2 -2
View File
@@ -14,7 +14,7 @@ const routeAuth = Auth.remove("authorization")
// (helper builds the URL) or `baseURL` directly.
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
export type ModelOptions = AzureURL &
export type LanguageModelOptions = AzureURL &
RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly apiVersion?: string
@@ -22,7 +22,7 @@ export type ModelOptions = AzureURL &
readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Config = ModelOptions
export type Config = LanguageModelOptions
export type Settings = ProviderPackage.Settings &
AzureURL & {
+6 -6
View File
@@ -9,14 +9,14 @@ 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"> &
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL: string
readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => {
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
if (endpoint) return endpoint === "responses"
const model = String(modelID)
const match = /^gpt-(\d+)/.exec(model)
@@ -29,24 +29,24 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id })
const defaults = (options: ModelOptions) => {
const defaults = (options: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest
}
const configuredResponsesRoute = (options: ModelOptions) =>
const configuredResponsesRoute = (options: LanguageModelOptions) =>
responsesRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
const configuredChatRoute = (options: ModelOptions) =>
const configuredChatRoute = (options: LanguageModelOptions) =>
chatRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
export const configure = (options: ModelOptions) => {
export const configure = (options: LanguageModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
+87 -13
View File
@@ -4,28 +4,79 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions {
readonly [key: string]: unknown
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
}
export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
@@ -47,7 +98,7 @@ export const protocol = Protocol.make({
body: {
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
@@ -78,16 +129,39 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream,
})
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return {
...(openrouter.usage === true
...options,
...(usage === undefined || usage === true
? { usage: { include: true } }
: isRecord(openrouter.usage)
? { usage: openrouter.usage }
: {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
: usage === false
? { usage: { include: false } }
: isRecord(usage)
? { usage }
: {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
}
}
@@ -101,7 +175,7 @@ export const route = Route.make({
export const routes = [route]
const configuredRoute = (input: ModelOptions) => {
const configuredRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({
...rest,
@@ -110,7 +184,7 @@ const configuredRoute = (input: ModelOptions) => {
})
}
export const configure = (input: ModelOptions = {}) => {
export const configure = (input: LanguageModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
+4 -4
View File
@@ -16,7 +16,7 @@ export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
@@ -53,7 +53,7 @@ export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const configuredResponsesRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({
...rest,
@@ -62,7 +62,7 @@ const configuredResponsesRoute = (input: ModelOptions) => {
})
}
const configuredChatRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({
...rest,
@@ -71,7 +71,7 @@ const configuredChatRoute = (input: ModelOptions) => {
})
}
export const configure = (input: ModelOptions = {}) => {
export const configure = (input: LanguageModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
+9 -5
View File
@@ -22,13 +22,17 @@ export type ProviderAuthOption<Mode extends ApiKeyMode> =
| AuthOverride
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
export type ModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> & ProviderAuthOption<Mode>
export type LanguageModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> &
ProviderAuthOption<Mode>
export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: ModelOptions<Base, Mode>]
: readonly [options: ModelOptions<Base, Mode>]
export type LanguageModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: LanguageModelOptions<Base, Mode>]
: readonly [options: LanguageModelOptions<Base, Mode>]
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (id: string, ...args: ModelArgs<Base, Mode>) => Model
export type LanguageModelFactory<Base, Mode extends ApiKeyMode, LanguageModel> = (
id: string,
...args: LanguageModelArgs<Base, Mode>
) => LanguageModel
/**
* Require at least one of the keys in `T`. Use for option shapes where any
+7 -7
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -11,7 +11,7 @@ export class MissingCredentialError extends Error {
}
export type CredentialError = MissingCredentialError | Config.ConfigError
export type AuthError = CredentialError | LLMError
export type AuthError = CredentialError | AIError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
@@ -100,7 +100,7 @@ export const headers = (input: Headers.Input) =>
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply)
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, AIError>) => auth(apply)
export const passthrough = none
@@ -134,9 +134,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
return render(source)
}
const toLLMError = (error: AuthError): LLMError => {
const toAIError = (error: AuthError): AIError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new LLMError({
return new AIError({
module: "Auth",
method: "apply",
reason:
@@ -150,7 +150,7 @@ const toLLMError = (error: AuthError): LLMError => {
export const toEffect =
(input: Definition) =>
(authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> =>
input.apply(authInput).pipe(Effect.mapError(toLLMError))
(authInput: AuthInput): Effect.Effect<Headers.Headers, AIError> =>
input.apply(authInput).pipe(Effect.mapError(toAIError))
export * as Auth from "./auth"
+52 -38
View File
@@ -5,20 +5,20 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, ProtocolID, ProviderOptions } from "../schema"
import type { ProtocolID, ProviderOptions } from "../schema"
import {
AIError,
GenerationOptions,
HttpOptions,
LLMRequest,
LLMResponse,
Model,
ModelLimits,
LLMError as LLMErrorClass,
LanguageModel,
LanguageModelLimits,
LLMEvent,
ProviderID,
mergeGenerationOptions,
@@ -30,7 +30,7 @@ export interface RouteBody<Body> {
/** Schema for the validated provider-native body sent as the JSON request. */
readonly schema: Schema.Codec<Body, unknown>
/** Build the provider-native body from a common `LLMRequest`. */
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface Route<Body, Prepared = unknown> {
@@ -45,13 +45,19 @@ export interface Route<Body, Prepared = unknown> {
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, AIError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
) => Stream.Stream<LLMEvent, LLMError>
) => Stream.Stream<LLMEvent, AIError>
}
// Route registries intentionally erase body generics after construction.
@@ -62,13 +68,13 @@ export type AnyRoute = Route<any, any>
export type HttpOptionsInput = HttpOptions.Input
export type RouteModelInput = Omit<Model.Input, "provider" | "route">
export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "route">
export type RouteRoutedModelInput = Omit<Model.Input, "route">
export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults {
readonly headers?: Record<string, string>
readonly limits?: ModelLimits
readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
@@ -76,7 +82,7 @@ export interface RouteDefaults {
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
readonly limits?: ModelLimits.Input
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -90,14 +96,17 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly endpoint?: EndpointPatch<Body>
}
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedLanguageModelInput,
) => {
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<Options>({
return LanguageModel.make<Options>({
...mapped,
provider,
route,
@@ -110,7 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base,
...patch,
headers,
limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits),
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
@@ -145,12 +154,16 @@ export interface Interface {
readonly generate: GenerateMethod
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError>
}
export interface GenerateMethod {
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -214,11 +227,11 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof LLMErrorClass) return failed
if (failed instanceof AIError) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) =>
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => {
let terminal = false
return events.pipe(
@@ -284,9 +297,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults),
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request) =>
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
request,
@@ -294,6 +307,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -359,14 +373,14 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved)
const prepared = yield* route.prepareTransport(body, resolved, options)
return {
request: resolved,
@@ -389,17 +403,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
}
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -408,24 +422,24 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
) as Stream.Stream<LLMEvent, LLMError>
) as Stream.Stream<LLMEvent, AIError>
}
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request)
}) as Effect.Effect<LLMResponse, LLMError>
return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, AIError>
}
export const streamRequest = (request: LLMRequest) =>
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request)
return (yield* Service).stream(request, options)
}),
)
+5 -5
View File
@@ -12,7 +12,7 @@ import {
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
LLMError,
AIError,
TransportReason,
} from "../schema"
import { classifyProviderFailure } from "../provider-error"
@@ -20,10 +20,10 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface {
readonly execute: (
request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384
const REDACTED = "<redacted>"
@@ -220,7 +220,7 @@ const statusError =
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
return yield* new LLMError({
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
reason: classifyProviderFailure({
@@ -246,7 +246,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) =>
new LLMError({
new AIError({
module: "RequestExecutor",
method: "execute",
reason: new TransportReason({
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Stream } from "effect"
import * as ProviderShared from "../protocols/shared"
import type { LLMError } from "../schema"
import type { AIError } from "../schema"
/**
* Decode a streaming HTTP response body into provider-protocol frames.
@@ -18,7 +18,7 @@ import type { LLMError } from "../schema"
*/
export interface Definition<Frame> {
readonly id: string
readonly frame: (bytes: Stream.Stream<Uint8Array, LLMError>) => Stream.Stream<Frame, LLMError>
readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
}
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+4 -3
View File
@@ -1,13 +1,14 @@
export { Route, LLMClient } from "./client"
export type {
Route as RouteShape,
RouteModelInput,
RouteRoutedModelInput,
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
RouteDefaults,
RouteDefaultsInput,
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
StreamOptions,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
@@ -22,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+3 -3
View File
@@ -1,5 +1,5 @@
import { Schema, type Effect } from "effect"
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
/**
* The semantic API contract of one model server family.
@@ -47,7 +47,7 @@ export interface ProtocolBody<Body> {
/** Schema for the validated provider-native body sent as the JSON request. */
readonly schema: Schema.Codec<Body, unknown>
/** Build the provider-native body from a common `LLMRequest`. */
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface ProtocolStream<Frame, Event, State> {
@@ -56,7 +56,7 @@ export interface ProtocolStream<Frame, Event, State> {
/** Initial parser state. Called once per response with the resolved request. */
readonly initial: (request: LLMRequest) => State
/** Translate one event into emitted `LLMEvent`s plus the next state. */
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean
/** Optional flush emitted when the framed stream ends. */
+12 -55
View File
@@ -28,57 +28,9 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
@@ -120,14 +72,19 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
jsonRequestParts({
...prepareInput,
}).pipe(
Effect.map((parts) => ({
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing,
})),
),
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
+13 -7
View File
@@ -3,21 +3,26 @@ import { Endpoint } from "../endpoint"
import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema"
import type { AIError, LLMRequest } from "../../schema"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
readonly frames: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
) => Stream.Stream<Frame, LLMError>
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
}
export interface TransportPrepareInput<Body> {
@@ -27,6 +32,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
+10 -10
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema"
import { AIError, TransportReason } from "../../schema"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
@@ -10,13 +10,13 @@ export interface WebSocketRequest {
}
export interface WebSocketConnection {
readonly sendText: (message: string) => Effect.Effect<void, LLMError>
readonly messages: Stream.Stream<string | Uint8Array, LLMError>
readonly sendText: (message: string) => Effect.Effect<void, AIError>
readonly messages: Stream.Stream<string | Uint8Array, AIError>
readonly close: Effect.Effect<void, never>
}
export interface Interface {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, LLMError>
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
}
type WebSocketConstructorWithHeaders = new (
@@ -24,14 +24,14 @@ type WebSocketConstructorWithHeaders = new (
options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
const transportError = (
method: string,
message: string,
input: { readonly url?: string; readonly kind?: string } = {},
) =>
new LLMError({
new AIError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
@@ -59,7 +59,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}),
)
}
return Effect.callback<void, LLMError>((resume, signal) => {
return Effect.callback<void, AIError>((resume, signal) => {
const cleanup = () => {
ws.removeEventListener("open", onOpen)
ws.removeEventListener("error", onError)
@@ -138,10 +138,10 @@ export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ o
export const fromWebSocket = (
ws: globalThis.WebSocket,
input: WebSocketRequest,
): Effect.Effect<WebSocketConnection, LLMError> =>
): Effect.Effect<WebSocketConnection, AIError> =>
Effect.gen(function* () {
yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, LLMError | Cause.Done<void>>(128)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
@@ -213,7 +213,7 @@ export interface JsonPrepared {
}
export interface JsonInput<Body, Message> {
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, LLMError>
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, AIError>
readonly encodeMessage: (message: Message) => string
}
+20 -20
View File
@@ -2,28 +2,28 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("LLM.HttpResponseDetails")({
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("LLM.HttpRateLimitDetails")({
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI.HttpRateLimitDetails")({
retryAfterMs: Schema.optional(Schema.Number),
limit: Schema.optional(Schema.Record(Schema.String, Schema.String)),
remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)),
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
request: HttpRequestDetails,
response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String),
@@ -32,7 +32,7 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"),
message: Schema.String,
parameter: Schema.optional(Schema.String),
@@ -41,18 +41,18 @@ export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LL
http: Schema.optional(HttpContext),
}) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
_tag: Schema.tag("NoRoute"),
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
get message() {
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
}
}
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
_tag: Schema.tag("Authentication"),
message: Schema.String,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
@@ -60,7 +60,7 @@ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LL
http: Schema.optional(HttpContext),
}) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
_tag: Schema.tag("RateLimit"),
message: Schema.String,
retryAfterMs: Schema.optional(Schema.Number),
@@ -69,21 +69,21 @@ export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.Ra
http: Schema.optional(HttpContext),
}) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"),
message: Schema.String,
status: Schema.optional(Schema.Number),
@@ -92,7 +92,7 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext),
}) {}
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
kind: Schema.optional(Schema.String),
@@ -101,7 +101,7 @@ export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Tr
}) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"LLM.Error.InvalidProviderOutput",
"AI.Error.InvalidProviderOutput",
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
@@ -110,7 +110,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"),
message: Schema.String,
status: Schema.optional(Schema.Number),
@@ -118,7 +118,7 @@ export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("
http: Schema.optional(HttpContext),
}) {}
export const LLMErrorReason = Schema.Union([
export const AIErrorReason = Schema.Union([
InvalidRequestReason,
NoRouteReason,
AuthenticationReason,
@@ -130,12 +130,12 @@ export const LLMErrorReason = Schema.Union([
InvalidProviderOutputReason,
UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag"))
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
module: Schema.String,
method: Schema.String,
reason: LLMErrorReason,
reason: AIErrorReason,
}) {
override readonly cause = this.reason
+1 -1
View File
@@ -48,7 +48,7 @@ import { ProviderFailureClassification } from "./errors"
* — for fields we don't normalize and for billing-level audit trails.
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
export class Usage extends Schema.Class<Usage>("AI.Usage")({
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
+4 -3
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm"
import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata }
@@ -11,10 +12,10 @@ export type ProtocolID = Schema.Schema.Type<typeof ProtocolID>
export const RouteID = Schema.String
export type RouteID = Schema.Schema.Type<typeof RouteID>
export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID"))
export const ModelID = Schema.String.pipe(Schema.brand("AI.ModelID"))
export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
export const ProviderID = Schema.String.pipe(Schema.brand("AI.ProviderID"))
export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
+4 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
const systemPartSchema = Schema.Struct({
@@ -124,6 +124,7 @@ export const ToolCallPart = Object.assign(
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -168,6 +169,7 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" })
@@ -261,7 +263,7 @@ export namespace ToolChoice {
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: ModelSchema,
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition),
+55 -41
View File
@@ -50,7 +50,7 @@ export const mergeProviderOptions = (
return Object.keys(result).length === 0 ? undefined : result
}
export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
body: Schema.optional(JsonSchema),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -121,31 +121,32 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace ModelLimits {
export type Input = ModelLimits | ConstructorParameters<typeof ModelLimits>[0]
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `ModelLimits` class. */
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
}
export class ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({
limits: Schema.optional(ModelLimits),
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
}) {}
export namespace ModelDefaults {
export namespace LanguageModelDefaults {
export type Input =
| ModelDefaults
| LanguageModelDefaults
| {
readonly limits?: ModelLimits.Input
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -153,9 +154,9 @@ export namespace ModelDefaults {
/** Normalize selected-model request defaults without applying precedence. */
export const make = (input: Input) => {
if (input instanceof ModelDefaults) return input
return new ModelDefaults({
limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits),
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
@@ -163,30 +164,39 @@ export namespace ModelDefaults {
}
}
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
export const LanguageModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type<
typeof LanguageModelMaxTokensFieldCompatibility
>
export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompatibility>(
"LLM.LanguageModelCompatibility",
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
}) {}
export namespace ModelCompatibility {
export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0]
export namespace LanguageModelCompatibility {
export type Input = LanguageModelCompatibility | ConstructorParameters<typeof LanguageModelCompatibility>[0]
/** Normalize model/upstream compatibility metadata without projecting requests. */
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
export const make = (input: Input) =>
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
}
export class Model<Options extends ProviderOptions = ProviderOptions> {
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
constructor(input: Model.ConstructorInput) {
constructor(input: LanguageModel.ConstructorInput) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@@ -194,17 +204,18 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility
}
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) {
return new Model<Options>({
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
defaults: input.defaults === undefined ? undefined : LanguageModelDefaults.make(input.defaults),
compatibility:
input.compatibility === undefined ? undefined : LanguageModelCompatibility.make(input.compatibility),
})
}
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput {
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -214,37 +225,40 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
}
}
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) {
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model
return Model.make<Options>({
...Model.input(model),
return LanguageModel.make<Options>({
...LanguageModel.input(model),
...patch,
})
}
}
export namespace Model {
export namespace LanguageModel {
export type ConstructorInput = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
}
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: ModelDefaults.Input
readonly compatibility?: ModelCompatibility.Input
readonly defaults?: LanguageModelDefaults.Input
readonly compatibility?: LanguageModelCompatibility.Input
}
}
export type ModelInput = Model.Input
export type LanguageModelInput = LanguageModel.Input
export type ModelProviderOptions<SelectedModel> = SelectedModel extends Model<infer Options> ? Options : never
export type LanguageModelProviderOptions<SelectedModel> =
SelectedModel extends LanguageModel<infer Options> ? Options : never
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export const LanguageModelSchema = Schema.declare((value): value is LanguageModel => value instanceof LanguageModel, {
expected: "LLM.LanguageModel",
})
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]),
+3 -3
View File
@@ -5,13 +5,13 @@ import {
LLMEvent,
LLMResponse,
type FinishReasonDetails,
type LLMError,
type AIError,
type LLMRequest,
type UsageInput,
} from "./schema"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -63,7 +63,7 @@ export const textWithUsage = (value: string, id: string, inputTokens: number) =>
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) =>
export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
+3 -3
View File
@@ -3,11 +3,11 @@ import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { compileRequest } from "../src/route/client"
import { Model } from "../src/schema"
import { LanguageModel } 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: LanguageModel, patch: Partial<LanguageModel.Input>) => LanguageModel.update(model, patch)
const Json = Schema.fromJsonString(Schema.Unknown)
const encodeJson = Schema.encodeSync(Json)
@@ -86,7 +86,7 @@ const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local"
const request = LLM.request({
id: "req_1",
model: Model.make({
model: LanguageModel.make({
id: "fake-model",
provider: "fake-provider",
route: configuredFake,
+4 -4
View File
@@ -1,6 +1,6 @@
import { Config } from "effect"
import { Auth } from "../src/route"
import type { ModelFactory } from "../src/route/auth-options"
import type { LanguageModelFactory } from "../src/route/auth-options"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
import * as Anthropic from "../src/providers/anthropic"
@@ -23,13 +23,13 @@ type BaseOptions = {
readonly headers?: Record<string, string>
}
type Model = {
type LanguageModel = {
readonly id: string
}
declare const auth: Auth.Definition
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
const configApiKey = Config.redacted("OPENAI_API_KEY")
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
+2 -2
View File
@@ -4,12 +4,12 @@ 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 { LanguageModel } 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: LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
prompt: "hello",
})
+54 -17
View File
@@ -137,24 +137,61 @@ describe("request option precedence", () => {
),
)
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(Effect.flip)
it.effect("transforms the final HTTP request after serialization and authentication", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
+10 -2
View File
@@ -1,4 +1,12 @@
import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src"
import {
LLM,
Message,
ToolCallPart,
ToolDefinition,
ToolResultPart,
type ContentPart,
type LanguageModel,
} from "../src"
export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
export const toolContinuation = ["tool-call", "tool-result"] as const
@@ -40,7 +48,7 @@ export const continuationTool = ToolDefinition.make({
export function continuationRequest(input: {
readonly id: string
readonly model: Model
readonly model: LanguageModel
readonly features: ReadonlyArray<ContinuationFeature>
readonly image?: string
}) {
+2 -2
View File
@@ -2,11 +2,11 @@ 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"
import { LanguageModel } from "../src/schema"
const request = () =>
LLM.request({
model: Model.make({
model: LanguageModel.make({
id: "model-1",
provider: "test",
route: OpenAIChat.route,
+26 -23
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMError } from "../src"
import { LLM, AIError } from "../src"
import { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http"
@@ -58,13 +58,13 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
),
)
const expectLLMError = (error: unknown) => {
expect(error).toBeInstanceOf(LLMError)
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
return error
}
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("classifies context overflow responses", () =>
@@ -72,7 +72,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
),
)
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
it.effect("classifies generic HTTP 413 payload errors", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
@@ -101,7 +104,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
@@ -114,7 +117,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -131,7 +134,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -145,7 +148,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error).toMatchObject({
reason: {
_tag: "RateLimit",
@@ -187,7 +190,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
}).pipe(
@@ -201,7 +204,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
retryAfterMs: 0,
@@ -234,7 +237,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0,
@@ -277,7 +280,7 @@ describe("RequestExecutor", () => {
),
)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1)
}),
@@ -290,7 +293,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe(
Effect.provide(
@@ -313,7 +316,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384)
@@ -332,7 +335,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
expect(errorHttp(error)?.body).not.toContain("body-secret")
@@ -353,7 +356,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectLLMError(error)
expectAIError(error)
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
expect(errorHttp(error)?.body).toContain("authorization <redacted>")
expect(errorHttp(error)?.body).not.toContain("query-secret-123")
@@ -392,7 +395,7 @@ describe("RequestExecutor", () => {
Effect.flip,
)
expectLLMError(error)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(yield* Ref.get(attempts)).toBe(1)
}),
+3 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import {
@@ -26,6 +26,8 @@ describe("public exports", () => {
expect(LLM.request).toBeFunction()
expect(LLMClient.Service).toBeFunction()
expect(LLMClient.layer).toBeDefined()
expect(AIError).toBeFunction()
expect(LanguageModel.make).toBeFunction()
expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make)
+3 -3
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src"
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
@@ -40,8 +40,8 @@ LLM.generateObject({
providerOptions: { example: { mode: false } },
})
declare const generic: Model
declare const generic: LanguageModel
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: ModelProviderOptions<typeof model> = { example: { mode: "fast" } }
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options
+13 -13
View File
@@ -6,7 +6,7 @@ import {
GenerationOptions,
LLMRequest,
Message,
Model,
LanguageModel,
ToolCallPart,
ToolChoice,
ToolDefinition,
@@ -20,13 +20,13 @@ 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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "You are concise.",
prompt: "Say hello.",
})
expect(request).toBeInstanceOf(LLMRequest)
expect(request.model).toBeInstanceOf(Model)
expect(request.model).toBeInstanceOf(LanguageModel)
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." }])
@@ -37,7 +37,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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.",
})
const updated = LLMRequest.update(base, {
@@ -54,7 +54,7 @@ describe("llm constructors", () => {
test("keeps request options separate from route defaults", () => {
const request = LLM.request({
model: Model.make({
model: LanguageModel.make({
id: "fake-model",
provider: "fake",
route: chatRoute.with({
@@ -81,7 +81,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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.",
})
const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] })
@@ -94,19 +94,19 @@ describe("llm constructors", () => {
})
test("updates canonical models from the model datatype", () => {
const base = Model.make({
const base = LanguageModel.make({
id: "fake-model",
provider: "fake",
route: chatRoute,
})
const updated = Model.update(base, {
const updated = LanguageModel.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = Model.input(updated)
const updatedInput = LanguageModel.input(updated)
expect(updated).toBeInstanceOf(Model)
expect(updated).toBeInstanceOf(LanguageModel)
expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
@@ -114,7 +114,7 @@ describe("llm constructors", () => {
expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake")
expect(Model.update(updated, {})).toBe(updated)
expect(LanguageModel.update(updated, {})).toBe(updated)
})
test("carries model defaults and compatibility through route model selection", () => {
@@ -155,7 +155,7 @@ describe("llm constructors", () => {
expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
expect(
LLM.request({
model: Model.make({
model: LanguageModel.make({
id: "fake-model",
provider: "fake",
route: chatRoute,
@@ -181,7 +181,7 @@ describe("llm constructors", () => {
{ type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
])
const request = LLM.request({
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "Initial operator prompt.",
messages: [Message.user("Review this."), update],
})
+22 -4
View File
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
})
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
"ProviderInternal",
"ProviderInternal",
])
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -9,9 +9,39 @@ LLM.request({
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
// @ts-expect-error Gemini safety settings require a threshold for every category.
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "future-tier",
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
})
@@ -5,6 +5,28 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
+4 -4
View File
@@ -1,9 +1,9 @@
import { Provider } from "../src/provider"
import { ProviderID, type Model } from "../src/schema"
import { ProviderID, type LanguageModel } 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) => LanguageModel
declare const requiredModel: (id: string, options: { readonly baseURL: string }) => LanguageModel
declare const chat: (id: string, options: { readonly apiKey: string }) => LanguageModel
Provider.make({
id: ProviderID.make("example"),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
import { LLM, AIError, Message, ToolCallPart } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import { weatherToolName } from "../recorded-scenarios"
@@ -37,7 +37,7 @@ describe("Anthropic Messages sad-path recorded", () => {
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
@@ -684,9 +684,7 @@ describe("Anthropic Messages route", () => {
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
@@ -935,7 +933,7 @@ describe("Anthropic Messages route", () => {
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
}),
)
@@ -1009,7 +1007,7 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
+31 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Gemini from "../../src/protocols/gemini"
@@ -41,7 +41,14 @@ describe("Gemini route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "priority",
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
},
},
}),
)
const filtered = yield* compileRequest(
@@ -49,12 +56,33 @@ describe("Gemini route", () => {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
)
const defaulted = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
}),
)
const emptySafetySettings = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { safetySettings: [] } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
})
expect(prepared.body.cachedContent).toBe("cachedContents/example")
expect(prepared.body.safetySettings).toEqual([
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
])
expect(prepared.body.serviceTier).toBe("priority")
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "high",
})
expect(emptySafetySettings.body.safetySettings).toEqual([])
}),
)
@@ -684,7 +712,7 @@ describe("Gemini route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
@@ -181,23 +181,6 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse, Model } from "../../src"
import { LLM, LLMEvent, LLMResponse, LanguageModel } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -12,7 +12,7 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop
const cases = [
{
name: "OpenRouter",
model: Model.update(
model: LanguageModel.update(
OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
@@ -25,7 +25,7 @@ const cases = [
},
{
name: "Vercel AI Gateway",
model: Model.update(
model: LanguageModel.update(
OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
@@ -4,11 +4,11 @@ import { HttpClientRequest } from "effect/unstable/http"
import {
HttpOptions,
LLM,
LLMError,
AIError,
LLMEvent,
LLMRequest,
Message,
Model,
LanguageModel,
ToolCallPart,
ToolDefinition,
Usage,
@@ -104,7 +104,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
model: LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [
Message.assistant([
{
@@ -130,7 +130,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
model: LanguageModel.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
}),
).pipe(Effect.flip)
@@ -171,7 +171,9 @@ describe("OpenAI Chat route", () => {
it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate(
LLMRequest.update(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
model: LanguageModel.update(model, {
route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
}),
}),
).pipe(
Effect.provide(
@@ -624,7 +626,7 @@ describe("OpenAI Chat route", () => {
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
Effect.provide(
fixedResponse(
@@ -1172,7 +1174,7 @@ describe("OpenAI Chat route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
@@ -144,6 +144,20 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
const prepared = yield* compileRequest(
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
)
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
expect(prepared.body).not.toHaveProperty("max_tokens")
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -3,11 +3,11 @@ import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
LLMError,
AIError,
LLMEvent,
LLMRequest,
Message,
Model,
LanguageModel,
ToolCallPart,
ToolDefinition,
ToolResultPart,
@@ -304,7 +304,9 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
model: LanguageModel.update(model, {
route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
}),
}),
).pipe(
Effect.provide(
@@ -985,9 +987,7 @@ describe("OpenAI Responses route", () => {
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
),
Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
@@ -1843,7 +1843,7 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" })
}),
)
@@ -2037,7 +2037,7 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
+177 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src"
import { CacheHint, LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -27,10 +27,131 @@ describe("OpenRouter", () => {
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
usage: { include: true },
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("lowers manual assistant and tool-result cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "Done",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
}),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
])
}),
)
it.effect("caps manual cache controls at four breakpoints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
prompt: "Hello",
}),
)
const system = prepared.body.messages[0]
expect(system?.role).toBe("system")
expect(
system && Array.isArray(system.content)
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
: [],
).toHaveLength(4)
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: { messages: "latest-assistant" },
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
])
}),
)
it.effect("allows usage accounting to be disabled explicitly", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: { usage: false } },
}).model("openai/gpt-4o-mini"),
cache: "none",
prompt: "Hello",
}),
)
expect(prepared.body.usage).toEqual({ include: false })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -42,6 +163,13 @@ describe("OpenRouter", () => {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
},
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
@@ -53,10 +181,54 @@ describe("OpenRouter", () => {
usage: { include: true },
reasoning: { effort: "high" },
prompt_cache_key: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
})
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
usage: "yes",
models: "anthropic/claude-sonnet-4.6",
provider: [],
plugins: {},
web_search_options: [],
debug: [],
user: 123,
reasoning: [],
promptCacheKey: 123,
future_option: { enabled: true },
}
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: invalid },
}).model("openai/gpt-4o-mini"),
prompt: "Hello",
}),
)
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
expect(prepared.body).not.toHaveProperty("usage")
expect(prepared.body).not.toHaveProperty("models")
expect(prepared.body).not.toHaveProperty("provider")
expect(prepared.body).not.toHaveProperty("plugins")
expect(prepared.body).not.toHaveProperty("web_search_options")
expect(prepared.body).not.toHaveProperty("debug")
expect(prepared.body).not.toHaveProperty("user")
expect(prepared.body).not.toHaveProperty("reasoning")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
@@ -104,6 +276,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant([
{
@@ -137,6 +310,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -162,6 +336,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -183,6 +358,7 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
import { LLM, LLMResponse, Message, ToolDefinition, type LanguageModel } from "../../src"
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { Tool } from "../../src/tool"
@@ -28,7 +28,7 @@ const targets: ReadonlyArray<{
readonly requires: string
readonly filename: string
readonly maxTokens: number
readonly model: Model
readonly model: LanguageModel
}> = [
{
id: "openai",
+2 -2
View File
@@ -1,7 +1,7 @@
import type { HttpRecorder } from "@opencode-ai/http-recorder"
import { describe } from "bun:test"
import { Effect } from "effect"
import type { Model } from "../src"
import type { LanguageModel } from "../src"
import { goldenScenarioTags, goldenScenarioTitle, 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: LanguageModel
readonly protocol?: string
readonly requires?: ReadonlyArray<string>
readonly transport?: Transport
+4 -4
View File
@@ -12,7 +12,7 @@ import {
toDefinitions,
type ContentPart,
type FinishReason,
type Model,
type LanguageModel,
} from "../src"
import { LLMClient } from "../src/route"
import { Tool } from "../src/tool"
@@ -54,7 +54,7 @@ export const weatherRuntimeTool = Tool.make({
export const weatherToolLoopRequest = (input: {
readonly id: string
readonly model: Model
readonly model: LanguageModel
readonly system?: string
readonly maxTokens?: number
readonly temperature?: number | false
@@ -73,7 +73,7 @@ export const weatherToolLoopRequest = (input: {
export const goldenWeatherToolLoopRequest = (input: {
readonly id: string
readonly model: Model
readonly model: LanguageModel
readonly maxTokens?: number
readonly temperature?: number | false
}) =>
@@ -163,7 +163,7 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
export interface GoldenScenarioContext {
readonly id: string
readonly model: Model
readonly model: LanguageModel
readonly maxTokens?: number
readonly temperature?: number | false
}
+28 -8
View File
@@ -1,11 +1,21 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, 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 {
AIError,
ContentPart,
InvalidRequestReason,
LLMEvent,
LLMRequest,
LanguageModel,
ModelID,
ProviderID,
Usage,
} from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new Model({
const model = new LanguageModel({
id: ModelID.make("fake-model"),
provider: ProviderID.make("fake-provider"),
route: OpenAIChat.route,
@@ -33,7 +43,7 @@ describe("llm schema", () => {
test("accepts custom route ids", () => {
const decoded = decodeLLMRequest({
model: Model.update(model, { route: OpenAIResponses.route }),
model: LanguageModel.update(model, { route: OpenAIResponses.route }),
system: [],
messages: [],
tools: [],
@@ -51,9 +61,7 @@ describe("llm schema", () => {
expect(
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
Usage,
)
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
})
test("content part tagged union exposes guards", () => {
@@ -62,7 +70,7 @@ describe("llm schema", () => {
})
})
describe("LLM.Usage", () => {
describe("AI.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or
// reasoning_tokens > completion_tokens — the negative would otherwise
@@ -88,3 +96,15 @@ describe("LLM.Usage", () => {
expect(new Usage({}).visibleOutputTokens).toBe(0)
})
})
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
module: "test",
method: "call",
reason: new InvalidRequestReason({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLMError } from "../src/schema"
import { AIError } from "../src/schema"
import { ToolStream } from "../src/protocols/utils/tool-stream"
import { it } from "./lib/effect"
@@ -67,7 +67,7 @@ describe("ToolStream", () => {
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(LLMError)
expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
}),
)
+1
View File
@@ -59,6 +59,7 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -4,6 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -54,7 +55,10 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const title = createMemo(() => {
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
})
const projectName = createMemo(() => {
const session = props.session()
@@ -143,7 +147,7 @@ export function TabNavItem(props: {
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title
titleEl.textContent = session.title ?? ""
setEditing(true)
requestAnimationFrame(() => {
@@ -302,7 +306,7 @@ export function TabNavItem(props: {
}}
data={{
projectName: projectName(),
title: props.session()?.title,
title: title(),
path: previewPath(),
serverName: serverLabel(),
}}
@@ -3,6 +3,7 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const title = createMemo(() => displayLabel(props.record.session))
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const title = createMemo(() => displayLabel(props.record.session))
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -5,6 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -14,7 +15,6 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => sessionTitle(props.session.title)
const title = () => displayLabel(props.session)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionTitle(props.session.title)}
value={displayLabel(props.session)}
gutter={10}
class="min-w-0 w-full"
>
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,8 +296,11 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -311,7 +314,10 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
@@ -329,7 +335,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -912,9 +918,10 @@ export function MessageTimeline(props: {
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
@@ -38,17 +38,19 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 79_400
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 0
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
-7
View File
@@ -1,7 +0,0 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
@@ -13,7 +14,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: input.title,
title: withTimestampedFallback(input),
agent: input.agent,
model: input.model,
version: "",
+3 -2
View File
@@ -58,8 +58,9 @@ export async function collectNodeAssets(target: NodeTarget) {
source: path.join(ptyRoot, relative),
})),
]
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
await Promise.all(unique.map((asset) => stat(asset.source)))
return unique
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -213,7 +214,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: session.title,
title: withTimestampedFallback(session),
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
-1
View File
@@ -133,7 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -1,5 +0,0 @@
import { Effect } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
-7
View File
@@ -116,13 +116,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.dismissed_getting_started === undefined
? {}
: {
hints: {
onboarding: !kv.dismissed_getting_started,
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
}
-1
View File
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
-4
View File
@@ -235,10 +235,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
+1 -1
View File
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { onboarding: false },
animations: false,
mouse: false,
})
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
})
+6 -5
View File
@@ -118,6 +118,7 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
@@ -397,7 +398,7 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -523,7 +524,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -685,7 +686,7 @@ export type Endpoint5_26Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
(
@@ -725,7 +726,7 @@ export type Endpoint5_26Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
@@ -775,7 +776,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string }
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly inputID?: SessionMessage.ID | undefined
}
}
@@ -299,7 +299,13 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
payload: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -462,6 +462,7 @@ export function make(options: ClientOptions) {
path: `/api/session`,
body: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
+14 -3
View File
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -1757,7 +1757,7 @@ export type SessionInfo = {
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number }
title: string
title?: string
location: LocationRef
subpath?: string
revert?: SessionRevert
@@ -2006,7 +2006,7 @@ export type SessionV1Info = {
cost?: number
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
share?: { url: string }
title: string
title?: string
agent?: string
model?: { id: string; providerID: string; variant?: string }
version: string
@@ -2662,24 +2662,35 @@ export type SessionListOutput = SessionsResponse
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"]
readonly title?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"]
readonly agent?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"]
readonly location?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
-3
View File
@@ -56,7 +56,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -100,7 +99,6 @@
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -122,7 +120,6 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+3 -3
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"prevIds": [
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
"db37a97f-9b5e-4c87-be8b-4feace35136c"
],
"ddl": [
{
@@ -1302,7 +1302,7 @@
},
{
"type": "text",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,

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