Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline d16e7ce3c3 feat(ai): add native Bedrock Mantle support 2026-08-02 03:02:03 +00:00
106 changed files with 1004 additions and 610 deletions
+5 -5
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `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.
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.
- 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.
@@ -76,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 `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.
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `Model` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `LLMError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -128,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 `LanguageModel`.
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`.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
@@ -138,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, LanguageModel, cache policy
options.ts Generation/Provider/Http options, Limits, Model, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, AIError, ToolFailure
errors.ts error reasons, LLMError, 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 `LanguageModel`.
endpoint, auth, transport, and hooks are bound behind `Model`.
### 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, AIError, Requirements>`.
`LLM.stream` returns an Effect `Stream<RunEvent, LLMError, 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 `AIError`
The Effect error channel is a tagged domain union rather than one `LLMError`
wrapper with nested reasons. Illustrative categories:
```ts
type AIError =
type LLMError =
| 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 `LanguageModel` |
| Public `Route` mental model | Hidden behind executable `Model` |
| `Provider.make` structural helper | Experimental declarative `Provider.define` |
| Schema classes as canonical values | Plain immutable values plus schema subpath |
| `LLM.updateRequest` | Object spread |
@@ -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 `AIError` | Tagged domain error union |
| One wrapped `LLMError` | 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.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`Model.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`.
+23 -23
View File
@@ -60,7 +60,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
## Highest-Risk Gaps
@@ -71,30 +71,31 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
## Native Namespace Shape
These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices
@@ -103,6 +104,5 @@ These are implementation/API slices, not separate npm packages.
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
+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 `LanguageModel` with a route value, protocol, transport, auth,
- Runtime capability is a `Model` with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas.
- If persisted identity needs to become executable, resolve it through an app
boundary first. Do not make `LLMRequest` recover behavior from a global route
@@ -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. **Language Model**
4. **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) => LanguageModel
readonly model: (id: string) => Model
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs
```
@@ -181,8 +181,8 @@ export const OpenAI = {
configure: configureOpenAI,
} satisfies ProviderFacade<
{
responses: (id: string) => LanguageModel
chat: (id: string) => LanguageModel
responses: (id: string) => Model
chat: (id: string) => Model
},
OpenAIConfig
>
@@ -528,7 +528,7 @@ The chosen split is:
```txt
Route = execution mechanics
Provider facade = configured route group
LanguageModel = selected executable model carrying route value
Model = 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 `LanguageModel`; durable model
- No executable `ModelRef`. The executable handle is `Model`; durable model
identity stays separate and cannot execute on its own.
## Implementation Todo
- [x] Replace the current executable `ModelRef` with `LanguageModel`.
- [x] Change `LanguageModel.route` to carry a route value, not a `RouteID` string.
- [x] Replace the current executable `ModelRef` with `Model`.
- [x] Change `Model.route` to carry a route value, not a `RouteID` string.
- [ ] Keep a separate durable model identity type for persisted/session/catalog
data, likely `{ providerID, modelID }`, and make it clear that it cannot
execute without resolver context.
@@ -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 `LanguageModel`; selected models now carry only
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
+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 { AIError } from "./schema"
import type { LLMError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
) => Effect.Effect<ImageResponse, AIError>
) => Effect.Effect<ImageResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, AIError> =>
): Effect.Effect<ImageResponse, LLMError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, AIError>
}) as Effect.Effect<ImageResponse, LLMError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
+8 -5
View File
@@ -1,10 +1,13 @@
import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, AIError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { HttpOptions, InvalidRequestReason, LLMError, 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, AIError>
readonly generate: (
request: ImageRequestFor<Options>,
execute: ImageExecute,
) => Effect.Effect<ImageResponse, LLMError>
}
export type ImageOptions = Record<string, unknown>
@@ -143,13 +146,13 @@ export function request(input: ImageRequest | ImageRequestInput) {
export function generate<const Model extends object>(
input: ImageRequestInput<Model>,
): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
): Effect.Effect<ImageResponse, LLMError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
export function generate(input: ImageRequest | ImageRequestInput) {
return Effect.try({
try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) =>
new AIError({
new LLMError({
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 {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
RouteModelInput,
RouteRoutedModelInput,
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,
LanguageModelFactory as ProviderLanguageModelFactory,
LanguageModelOptions as ProviderLanguageModelOptions,
ModelFactory as ProviderModelFactory,
ModelOptions as ProviderModelOptions,
} from "./provider"
export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package"
+21 -28
View File
@@ -4,33 +4,33 @@ import {
GenerationOptions,
HttpOptions,
InvalidProviderOutputReason,
AIError,
LLMError,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
LanguageModel,
Model,
SystemPart,
ToolChoice,
ToolDefinition,
type ContentPart,
type LanguageModelProviderOptions,
type ModelProviderOptions,
} 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<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
export type RequestInput<SelectedModel extends Model = Model> = Omit<
ConstructorParameters<typeof LLMRequest>[0],
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & {
readonly model: SelectedLanguageModel
readonly model: SelectedModel
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<LanguageModelProviderOptions<SelectedLanguageModel>>
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>>
readonly http?: HttpOptions.Input
}
@@ -38,9 +38,7 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream
export const request = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
) => {
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => {
const {
system: requestSystem,
prompt,
@@ -68,10 +66,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
RequestInput<SelectedLanguageModel>,
"tools" | "toolChoice"
>
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice">
export class GenerateObjectResponse<T> {
constructor(
@@ -88,15 +83,13 @@ export class GenerateObjectResponse<T> {
}
}
export interface GenerateObjectOptions<
S extends ToolSchema<any>,
SelectedLanguageModel extends LanguageModel = LanguageModel,
> extends GenerateObjectBase<SelectedLanguageModel> {
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
readonly schema: S
}
export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel = LanguageModel>
extends GenerateObjectBase<SelectedLanguageModel> {
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
/** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema
}
@@ -115,7 +108,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 AIError({
return yield* new LLMError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
@@ -125,7 +118,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
const object = yield* tool._decode(call.input).pipe(
Effect.mapError(
(error) =>
new AIError({
new LLMError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
@@ -145,16 +138,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 `AIError`.
* Decode failures surface as `LLMError`.
* 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 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<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(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
const { schema, ...rest } = options
+13 -11
View File
@@ -6,7 +6,7 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
AIError,
LLMError,
LLMEvent,
mergeJsonRecords,
Usage,
@@ -242,14 +242,16 @@ 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)],
@@ -723,7 +725,8 @@ 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"]) ?? {},
},
})
}
@@ -813,8 +816,7 @@ 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 [
{
@@ -978,7 +980,7 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
}
const onError = (event: AnthropicEvent) =>
new AIError({
new LLMError({
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 {
AIError,
LLMError,
LLMEvent,
Usage,
type CacheHint,
@@ -11,7 +11,7 @@ import {
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type LanguageModelToolSchemaCompatibility,
type ModelToolSchemaCompatibility,
type ProviderMetadata,
type ReasoningPart,
type ToolCallPart,
@@ -232,7 +232,7 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo
})
const lowerTools = (
compatibility: LanguageModelToolSchemaCompatibility | undefined,
compatibility: ModelToolSchemaCompatibility | undefined,
breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => {
@@ -274,7 +274,9 @@ 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 => ({
@@ -654,7 +656,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
return yield* new AIError({
return yield* new LLMError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
+3 -3
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
AIError,
LLMError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
@@ -125,7 +125,7 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
}
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new AIError({
new LLMError({
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>, AIError> => {
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
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 } })
+10 -4
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 {
AIError,
LLMError,
LLMEvent,
Usage,
type FinishReason,
@@ -434,7 +434,10 @@ 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] })
@@ -643,7 +646,10 @@ 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 => {
@@ -969,7 +975,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 AIError({
return new LLMError({
module: state.id,
method: "stream",
reason: classifyProviderFailure({ message, code }),
+3 -3
View File
@@ -6,7 +6,7 @@ import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
AIError,
LLMError,
LLMEvent,
Usage,
type FinishReason,
@@ -555,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 `AI.Usage` contract is
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
@@ -645,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 AIError({
return yield* new LLMError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
@@ -1,11 +1,11 @@
import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Route, type RouteRoutedModelInput } 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 OpenAICompatibleChatLanguageModelInput = RouteRoutedLanguageModelInput
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
/**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
@@ -1,10 +1,10 @@
import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { OpenResponses } from "./open-responses"
const ADAPTER = "openai-compatible-responses"
export type OpenAICompatibleResponsesLanguageModelInput = RouteRoutedLanguageModelInput
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
/**
* 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,
AIError,
LLMError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
@@ -85,7 +85,7 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
}
const invalidOutput = (message: string) =>
new AIError({
new LLMError({
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,
AIError,
LLMError,
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 `AI.Usage` contract, `inputTokens` and `outputTokens`
* Under the additive `LLM.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 AIError({
new LLMError({
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
* `AIError`.
* `LLMError`.
*/
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> =>
bytes.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
@@ -257,7 +257,7 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.St
* lands here.
*/
export const invalidRequest = (message: string) =>
new AIError({
new LLMError({
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
* `AIError` carrying the original parse-error message.
* `LLMError` carrying the original parse-error message.
*/
export const validateWith =
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
@@ -22,6 +22,8 @@ const signRequest = (input: {
readonly body: string
readonly headers: Headers.Headers
readonly credentials: Credentials
readonly service: string
readonly name: string
}) =>
Effect.tryPromise({
try: async () => {
@@ -34,23 +36,26 @@ const signRequest = (input: {
accessKeyId: input.credentials.accessKeyId,
secretAccessKey: input.credentials.secretAccessKey,
sessionToken: input.credentials.sessionToken,
service: "bedrock",
service: input.service,
}).sign()
return Object.fromEntries(signed.headers.entries())
},
catch: (error) =>
ProviderShared.invalidRequest(
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
`${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (credentials: Credentials | undefined) =>
export const sigV4 = (
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!credentials) {
return yield* ProviderShared.invalidRequest(
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
)
}
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
@@ -59,6 +64,8 @@ export const sigV4 = (credentials: Credentials | undefined) =>
body: input.body,
headers: headersForSigning,
credentials,
service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse",
})
return Headers.setAll(headersForSigning, signed)
})
@@ -1,9 +1,9 @@
import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image"
import { InvalidRequestReason, AIError } from "../../schema"
import { InvalidRequestReason, LLMError } from "../../schema"
const invalid = (module: string, message: string) =>
new AIError({
new LLMError({
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, AIError> => {
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
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, LanguageModelToolSchemaCompatibility } from "../../schema"
import type { JsonSchema, ModelToolSchemaCompatibility } 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: LanguageModelToolSchemaCompatibility | undefined,
compatibility: ModelToolSchemaCompatibility | undefined,
): JsonSchema => {
if (compatibility === undefined) return schema
switch (compatibility) {
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { LLMError, 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> | AIError): result is AIError =>
result instanceof AIError
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
result instanceof LLMError
/**
* 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> | AIError => {
): AppendOutcome<K> | LLMError => {
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> | AIError => {
): AppendOutcome<K> | LLMError => {
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,
AIError,
LLMError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
@@ -95,7 +95,7 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
}
const invalidOutput = (message: string) =>
new AIError({
new LLMError({
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, AIError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
import { InvalidProviderOutputReason, LLMError, 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 AIError({
new LLMError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+3 -3
View File
@@ -3,7 +3,7 @@ import {
AuthenticationReason,
ContentPolicyReason,
InvalidRequestReason,
AIError,
LLMError,
ProviderErrorEvent,
ProviderInternalReason,
QuotaExceededReason,
@@ -53,7 +53,7 @@ export const isContextOverflow = (message: string) =>
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof AIError
failure instanceof LLMError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
@@ -86,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): AIError["reason"] {
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
+2 -2
View File
@@ -1,4 +1,4 @@
import type { LanguageModel, ProviderOptions } from "./schema"
import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -15,7 +15,7 @@ export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options>
}
export * as ProviderPackage from "./provider-package"
+9 -9
View File
@@ -1,6 +1,6 @@
import type { LanguageModel, ModelID, ProviderID } from "./schema"
import type { Model, ModelID, ProviderID } from "./schema"
export type LanguageModelOptions = Pick<LanguageModel.Input, "defaults" | "compatibility">
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
/**
* Advanced structural provider definition helper. Built-in providers should
@@ -8,23 +8,23 @@ export type LanguageModelOptions = Pick<LanguageModel.Input, "defaults" | "compa
* chosen before model selection. The optional `apis` map remains for external
* structural providers that expose multiple route selectors behind one provider.
*/
export type LanguageModelFactory<Options extends LanguageModelOptions = LanguageModelOptions> = (
export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
id: string | ModelID,
options?: Options,
) => LanguageModel
) => Model
type AnyLanguageModelFactory = (...args: never[]) => LanguageModel
type AnyModelFactory = (...args: never[]) => Model
export interface Definition<Factory extends AnyLanguageModelFactory = LanguageModelFactory> {
export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
readonly id: ProviderID
readonly model: Factory
readonly apis?: Record<string, AnyLanguageModelFactory>
readonly apis?: Record<string, AnyModelFactory>
}
type DefinitionShape = {
readonly id: ProviderID
readonly model: (...args: never[]) => LanguageModel
readonly apis?: Record<string, (...args: never[]) => LanguageModel>
readonly model: (...args: never[]) => Model
readonly apis?: Record<string, (...args: never[]) => Model>
}
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
@@ -0,0 +1,107 @@
import { Auth } from "../route/auth"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { OpenAIChat } from "../protocols/openai-chat"
import { OpenAIResponses } from "../protocols/openai-responses"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth"
import { ProviderID, type ModelID } from "../schema"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = OpenAIResponses.route.with({
id: "bedrock-mantle-responses",
provider: id,
})
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
})
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
return rest
}
export const configure = (input: Config = {}) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: chat,
chat,
responses,
configure,
}
}
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
@@ -0,0 +1,2 @@
export { chatModel as model } from "../amazon-bedrock-mantle"
export type { Settings } from "../amazon-bedrock-mantle"
@@ -0,0 +1,2 @@
export { chatModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
@@ -0,0 +1,2 @@
export { responsesModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
+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 LanguageModelOptions = AzureURL &
export type ModelOptions = AzureURL &
RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly apiVersion?: string
@@ -22,7 +22,7 @@ export type LanguageModelOptions = AzureURL &
readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Config = LanguageModelOptions
export type Config = ModelOptions
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 LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL: string
readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["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: LanguageModelOptions) => {
const defaults = (options: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest
}
const configuredResponsesRoute = (options: LanguageModelOptions) =>
const configuredResponsesRoute = (options: ModelOptions) =>
responsesRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
const configuredChatRoute = (options: LanguageModelOptions) =>
const configuredChatRoute = (options: ModelOptions) =>
chatRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
export const configure = (options: LanguageModelOptions) => {
export const configure = (options: ModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
+1
View File
@@ -1,6 +1,7 @@
export * as Anthropic from "./anthropic"
export * as AnthropicCompatible from "./anthropic-compatible"
export * as AmazonBedrock from "./amazon-bedrock"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
+3 -3
View File
@@ -76,7 +76,7 @@ export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
@@ -175,7 +175,7 @@ export const route = Route.make({
export const routes = [route]
const configuredRoute = (input: LanguageModelOptions) => {
const configuredRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({
...rest,
@@ -184,7 +184,7 @@ const configuredRoute = (input: LanguageModelOptions) => {
})
}
export const configure = (input: LanguageModelOptions = {}) => {
export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
+4 -4
View File
@@ -16,7 +16,7 @@ export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
export type ModelOptions = 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: LanguageModelOptions) => {
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({
...rest,
@@ -62,7 +62,7 @@ const configuredResponsesRoute = (input: LanguageModelOptions) => {
})
}
const configuredChatRoute = (input: LanguageModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({
...rest,
@@ -71,7 +71,7 @@ const configuredChatRoute = (input: LanguageModelOptions) => {
})
}
export const configure = (input: LanguageModelOptions = {}) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
+5 -9
View File
@@ -22,17 +22,13 @@ export type ProviderAuthOption<Mode extends ApiKeyMode> =
| AuthOverride
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
export type LanguageModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> &
ProviderAuthOption<Mode>
export type ModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> & ProviderAuthOption<Mode>
export type LanguageModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: LanguageModelOptions<Base, Mode>]
: readonly [options: LanguageModelOptions<Base, Mode>]
export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: ModelOptions<Base, Mode>]
: readonly [options: ModelOptions<Base, Mode>]
export type LanguageModelFactory<Base, Mode extends ApiKeyMode, LanguageModel> = (
id: string,
...args: LanguageModelArgs<Base, Mode>
) => LanguageModel
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (id: string, ...args: ModelArgs<Base, Mode>) => Model
/**
* 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, AIError, type HttpOptions } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, 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 | AIError
export type AuthError = CredentialError | LLMError
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, AIError>) => auth(apply)
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply)
export const passthrough = none
@@ -134,9 +134,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
return render(source)
}
const toAIError = (error: AuthError): AIError => {
const toLLMError = (error: AuthError): LLMError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new AIError({
return new LLMError({
module: "Auth",
method: "apply",
reason:
@@ -150,7 +150,7 @@ const toAIError = (error: AuthError): AIError => {
export const toEffect =
(input: Definition) =>
(authInput: AuthInput): Effect.Effect<Headers.Headers, AIError> =>
input.apply(authInput).pipe(Effect.mapError(toAIError))
(authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> =>
input.apply(authInput).pipe(Effect.mapError(toLLMError))
export * as Auth from "./auth"
+26 -31
View File
@@ -10,15 +10,15 @@ import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { ProtocolID, ProviderOptions } from "../schema"
import type { LLMError, ProtocolID, ProviderOptions } from "../schema"
import {
AIError,
GenerationOptions,
HttpOptions,
LLMRequest,
LLMResponse,
LanguageModel,
LanguageModelLimits,
Model,
ModelLimits,
LLMError as LLMErrorClass,
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, AIError>
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
}
export interface Route<Body, Prepared = unknown> {
@@ -45,19 +45,17 @@ 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: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, AIError>
) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
) => Stream.Stream<LLMEvent, AIError>
) => Stream.Stream<LLMEvent, LLMError>
}
// Route registries intentionally erase body generics after construction.
@@ -68,13 +66,13 @@ export type AnyRoute = Route<any, any>
export type HttpOptionsInput = HttpOptions.Input
export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "route">
export type RouteModelInput = Omit<Model.Input, "provider" | "route">
export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export type RouteRoutedModelInput = Omit<Model.Input, "route">
export interface RouteDefaults {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits
readonly limits?: ModelLimits
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
@@ -82,7 +80,7 @@ export interface RouteDefaults {
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits.Input
readonly limits?: ModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -96,17 +94,14 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly endpoint?: EndpointPatch<Body>
}
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedLanguageModelInput,
) => {
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options>({
return Model.make<Options>({
...mapped,
provider,
route,
@@ -119,7 +114,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base,
...patch,
headers,
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
@@ -159,11 +154,11 @@ export interface StreamOptions {
}
export interface StreamMethod {
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError>
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
}
export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -227,11 +222,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 AIError) return failed
if (failed instanceof LLMErrorClass) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) =>
Stream.suspend(() => {
let terminal = false
return events.pipe(
@@ -297,8 +292,8 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults),
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options>(route, input),
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -422,18 +417,18 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
}),
) as Stream.Stream<LLMEvent, AIError>
) as Stream.Stream<LLMEvent, LLMError>
}
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, AIError>
}) as Effect.Effect<LLMResponse, LLMError>
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
+5 -5
View File
@@ -12,7 +12,7 @@ import {
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
AIError,
LLMError,
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, AIError>
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/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 AIError({
return yield* new LLMError({
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 AIError({
new LLMError({
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 { AIError } from "../schema"
import type { LLMError } from "../schema"
/**
* Decode a streaming HTTP response body into provider-protocol frames.
@@ -18,7 +18,7 @@ import type { AIError } from "../schema"
*/
export interface Definition<Frame> {
readonly id: string
readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
readonly frame: (bytes: Stream.Stream<Uint8Array, LLMError>) => Stream.Stream<Frame, LLMError>
}
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+2 -2
View File
@@ -1,8 +1,8 @@
export { Route, LLMClient } from "./client"
export type {
Route as RouteShape,
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
RouteModelInput,
RouteRoutedModelInput,
RouteDefaults,
RouteDefaultsInput,
AnyRoute,
+3 -3
View File
@@ -1,5 +1,5 @@
import { Schema, type Effect } from "effect"
import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
import type { LLMError, 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, AIError>
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
}
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>], AIError>
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
/** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean
/** Optional flush emitted when the framed stream ends. */
+7 -3
View File
@@ -3,7 +3,7 @@ import { Endpoint } from "../endpoint"
import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { AIError, LLMRequest } from "../../schema"
import type { LLMError, LLMRequest } from "../../schema"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
@@ -21,8 +21,12 @@ 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, AIError>
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
readonly frames: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
) => Stream.Stream<Frame, LLMError>
}
export interface TransportPrepareInput<Body> {
+10 -10
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { AIError, TransportReason } from "../../schema"
import { LLMError, 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, AIError>
readonly messages: Stream.Stream<string | Uint8Array, AIError>
readonly sendText: (message: string) => Effect.Effect<void, LLMError>
readonly messages: Stream.Stream<string | Uint8Array, LLMError>
readonly close: Effect.Effect<void, never>
}
export interface Interface {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, LLMError>
}
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/AI/WebSocketExecutor") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
const transportError = (
method: string,
message: string,
input: { readonly url?: string; readonly kind?: string } = {},
) =>
new AIError({
new LLMError({
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, AIError>((resume, signal) => {
return Effect.callback<void, LLMError>((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, AIError> =>
): Effect.Effect<WebSocketConnection, LLMError> =>
Effect.gen(function* () {
yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const messages = yield* Queue.bounded<string | Uint8Array, LLMError | 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, AIError>
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, LLMError>
readonly encodeMessage: (message: Message) => string
}
+19 -19
View File
@@ -5,25 +5,25 @@ import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("LLM.HttpResponseDetails")({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI.HttpRateLimitDetails")({
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("LLM.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>("AI.HttpContext")({
export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
request: HttpRequestDetails,
response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String),
@@ -32,7 +32,7 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.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>("AI
http: Schema.optional(HttpContext),
}) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
_tag: Schema.tag("NoRoute"),
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
get message() {
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
}
}
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.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>("AI
http: Schema.optional(HttpContext),
}) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.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>("AI.Error.Rat
http: Schema.optional(HttpContext),
}) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.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>("AI.Error.Transport")({
export class TransportReason extends Schema.Class<TransportReason>("LLM.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>("AI.Error.Tra
}) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"AI.Error.InvalidProviderOutput",
"LLM.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>("AI.Error.UnknownProvider")({
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.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 AIErrorReason = Schema.Union([
export const LLMErrorReason = Schema.Union([
InvalidRequestReason,
NoRouteReason,
AuthenticationReason,
@@ -130,12 +130,12 @@ export const AIErrorReason = Schema.Union([
InvalidProviderOutputReason,
UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag"))
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
module: Schema.String,
method: Schema.String,
reason: AIErrorReason,
reason: LLMErrorReason,
}) {
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>("AI.Usage")({
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
+3 -4
View File
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm"
export { ProviderMetadata }
@@ -12,10 +11,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("AI.ModelID"))
export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID"))
export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("AI.ProviderID"))
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
+2 -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, LanguageModelSchema, ProviderOptions } from "./options"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
const systemPartSchema = Schema.Struct({
@@ -263,7 +263,7 @@ export namespace ToolChoice {
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: LanguageModelSchema,
model: ModelSchema,
system: Schema.Array(SystemPart),
messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition),
+44 -53
View File
@@ -50,7 +50,7 @@ export const mergeProviderOptions = (
return Object.keys(result).length === 0 ? undefined : result
}
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({
body: Schema.optional(JsonSchema),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -121,32 +121,32 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
export namespace ModelLimits {
export type Input = ModelLimits | ConstructorParameters<typeof ModelLimits>[0]
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
/** Normalize model limit input into the canonical `ModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
export class ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({
limits: Schema.optional(ModelLimits),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
}) {}
export namespace LanguageModelDefaults {
export namespace ModelDefaults {
export type Input =
| LanguageModelDefaults
| ModelDefaults
| {
readonly limits?: LanguageModelLimits.Input
readonly limits?: ModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -154,9 +154,9 @@ export namespace LanguageModelDefaults {
/** Normalize selected-model request defaults without applying precedence. */
export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
if (input instanceof ModelDefaults) return input
return new ModelDefaults({
limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
@@ -164,39 +164,34 @@ export namespace LanguageModelDefaults {
}
}
export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const LanguageModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type<
typeof LanguageModelMaxTokensFieldCompatibility
>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompatibility>(
"LLM.LanguageModelCompatibility",
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
}) {}
export namespace LanguageModelCompatibility {
export type Input = LanguageModelCompatibility | ConstructorParameters<typeof LanguageModelCompatibility>[0]
export namespace ModelCompatibility {
export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0]
/** Normalize model/upstream compatibility metadata without projecting requests. */
export const make = (input: Input) =>
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
}
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
export class Model<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
constructor(input: LanguageModel.ConstructorInput) {
constructor(input: Model.ConstructorInput) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@@ -204,18 +199,17 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility
}
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) {
return new Model<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
defaults: input.defaults === undefined ? undefined : LanguageModelDefaults.make(input.defaults),
compatibility:
input.compatibility === undefined ? undefined : LanguageModelCompatibility.make(input.compatibility),
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
})
}
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -225,40 +219,37 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
}
}
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) {
if (Object.keys(patch).length === 0) return model
return LanguageModel.make<Options>({
...LanguageModel.input(model),
return Model.make<Options>({
...Model.input(model),
...patch,
})
}
}
export namespace LanguageModel {
export namespace Model {
export type ConstructorInput = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
}
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: LanguageModelDefaults.Input
readonly compatibility?: LanguageModelCompatibility.Input
readonly defaults?: ModelDefaults.Input
readonly compatibility?: ModelCompatibility.Input
}
}
export type LanguageModelInput = LanguageModel.Input
export type ModelInput = Model.Input
export type LanguageModelProviderOptions<SelectedModel> =
SelectedModel extends LanguageModel<infer Options> ? Options : never
export type ModelProviderOptions<SelectedModel> = SelectedModel extends Model<infer Options> ? Options : never
export const LanguageModelSchema = Schema.declare((value): value is LanguageModel => value instanceof LanguageModel, {
expected: "LLM.LanguageModel",
})
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]),
+3 -3
View File
@@ -5,13 +5,13 @@ import {
LLMEvent,
LLMResponse,
type FinishReasonDetails,
type AIError,
type LLMError,
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, AIError>
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
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: AIError, ...events: readonly LLMEvent[]) =>
export const failAfter = (error: LLMError, ...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 { LanguageModel } from "../src/schema"
import { Model } from "../src/schema"
import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
const updateModel = (model: LanguageModel, patch: Partial<LanguageModel.Input>) => LanguageModel.update(model, patch)
const updateModel = (model: Model, patch: Partial<Model.Input>) => Model.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: LanguageModel.make({
model: Model.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 { LanguageModelFactory } from "../src/route/auth-options"
import type { ModelFactory } 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 LanguageModel = {
type Model = {
readonly id: string
}
declare const auth: Auth.Definition
declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
const configApiKey = Config.redacted("OPENAI_API_KEY")
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
+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 { LanguageModel } from "../src/schema"
import { Model } from "../src/schema"
import { it } from "./lib/effect"
const request = LLM.request({
id: "req_auth",
model: LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
model: Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
prompt: "hello",
})
+2 -10
View File
@@ -1,12 +1,4 @@
import {
LLM,
Message,
ToolCallPart,
ToolDefinition,
ToolResultPart,
type ContentPart,
type LanguageModel,
} from "../src"
import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src"
export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
export const toolContinuation = ["tool-call", "tool-result"] as const
@@ -48,7 +40,7 @@ export const continuationTool = ToolDefinition.make({
export function continuationRequest(input: {
readonly id: string
readonly model: LanguageModel
readonly model: Model
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 { LanguageModel } from "../src/schema"
import { Model } from "../src/schema"
const request = () =>
LLM.request({
model: LanguageModel.make({
model: Model.make({
id: "model-1",
provider: "test",
route: OpenAIChat.route,
+20 -20
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, AIError } from "../src"
import { LLM, LLMError } 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 expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(AIError)
if (!(error instanceof AIError)) throw new Error("expected AIError")
const expectLLMError = (error: unknown) => {
expect(error).toBeInstanceOf(LLMError)
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
return error
}
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
const errorHttp = (error: LLMError) => ("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)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
@@ -90,7 +90,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
@@ -104,7 +104,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(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 })]))),
@@ -117,7 +117,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -134,7 +134,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -148,7 +148,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error).toMatchObject({
reason: {
_tag: "RateLimit",
@@ -190,7 +190,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
}).pipe(
@@ -204,7 +204,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
retryAfterMs: 0,
@@ -237,7 +237,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0,
@@ -280,7 +280,7 @@ describe("RequestExecutor", () => {
),
)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1)
}),
@@ -293,7 +293,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe(
Effect.provide(
@@ -316,7 +316,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384)
@@ -335,7 +335,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expectLLMError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
expect(errorHttp(error)?.body).not.toContain("body-secret")
@@ -356,7 +356,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectAIError(error)
expectLLMError(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")
@@ -395,7 +395,7 @@ describe("RequestExecutor", () => {
Effect.flip,
)
expectAIError(error)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(yield* Ref.get(attempts)).toBe(1)
}),
+1 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { ImageInput, 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,8 +26,6 @@ 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)
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
@@ -40,8 +40,8 @@ LLM.generateObject({
providerOptions: { example: { mode: false } },
})
declare const generic: LanguageModel
declare const generic: Model
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
const options: ModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options
+13 -13
View File
@@ -6,7 +6,7 @@ import {
GenerationOptions,
LLMRequest,
Message,
LanguageModel,
Model,
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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "You are concise.",
prompt: "Say hello.",
})
expect(request).toBeInstanceOf(LLMRequest)
expect(request.model).toBeInstanceOf(LanguageModel)
expect(request.model).toBeInstanceOf(Model)
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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: Model.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: LanguageModel.make({
model: Model.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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: Model.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 = LanguageModel.make({
const base = Model.make({
id: "fake-model",
provider: "fake",
route: chatRoute,
})
const updated = LanguageModel.update(base, {
const updated = Model.update(base, {
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = LanguageModel.input(updated)
const updatedInput = Model.input(updated)
expect(updated).toBeInstanceOf(LanguageModel)
expect(updated).toBeInstanceOf(Model)
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(LanguageModel.update(updated, {})).toBe(updated)
expect(Model.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: LanguageModel.make({
model: Model.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: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "Initial operator prompt.",
messages: [Message.user("Review this."), update],
})
@@ -23,12 +23,16 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[8].model).toBe(modules[9].model)
expect(modules[12].model).toBe(modules[13].model)
expect(modules[19].model).toBe(modules[20].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
+4 -4
View File
@@ -1,9 +1,9 @@
import { Provider } from "../src/provider"
import { ProviderID, type LanguageModel } from "../src/schema"
import { ProviderID, type Model } from "../src/schema"
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
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
Provider.make({
id: ProviderID.make("example"),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, AIError, Message, ToolCallPart } from "../../src"
import { LLM, LLMError, 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(AIError)
expect(error).toBeInstanceOf(LLMError)
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, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { CacheHint, LLM, LLMError, 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,7 +684,9 @@ 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" } },
])
}),
)
@@ -933,7 +935,7 @@ describe("Anthropic Messages route", () => {
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error).toBeInstanceOf(AIError)
expect(error).toBeInstanceOf(LLMError)
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
}),
)
@@ -1007,7 +1009,7 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
@@ -0,0 +1,111 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { AmazonBedrockMantle } from "../../src/providers"
import { compileRequest, LLMClient } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { recordedTests } from "../recorded-test"
const credentials = {
region: "us-east-2",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
route: "bedrock-mantle-chat",
protocol: "openai-chat",
body: { model: "openai.gpt-oss-120b" },
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
}),
)
it.effect("uses the Mantle endpoint and signing service", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
}),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({
apiKey: "test-key",
baseURL: "https://mantle.test/v1",
}).chat("openai.gpt-oss-safeguard-20b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
}),
)
})
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "openai-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
describe("Amazon Bedrock Mantle recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: AmazonBedrockMantle.configure({
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: "us-east-1",
}).responses("openai.gpt-oss-120b"),
prompt: "Reply with exactly: hello",
generation: { maxTokens: 256, temperature: 0 },
}),
)
expect(response.text.trim().toLowerCase()).toBe("hello")
}),
)
})
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { LLM, LLMError, 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"
@@ -712,7 +712,7 @@ describe("Gemini route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse, LanguageModel } from "../../src"
import { LLM, LLMEvent, LLMResponse, Model } 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: LanguageModel.update(
model: Model.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: LanguageModel.update(
model: Model.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,
AIError,
LLMError,
LLMEvent,
LLMRequest,
Message,
LanguageModel,
Model,
ToolCallPart,
ToolDefinition,
Usage,
@@ -104,7 +104,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
model: Model.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: LanguageModel.update(model, { compatibility: { reasoningField: "content" } }),
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
}),
).pipe(Effect.flip)
@@ -171,9 +171,7 @@ describe("OpenAI Chat route", () => {
it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate(
LLMRequest.update(request, {
model: LanguageModel.update(model, {
route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
}),
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}),
).pipe(
Effect.provide(
@@ -626,7 +624,7 @@ describe("OpenAI Chat route", () => {
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
Effect.provide(
fixedResponse(
@@ -1174,7 +1172,7 @@ describe("OpenAI Chat route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
@@ -3,11 +3,11 @@ import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
AIError,
LLMError,
LLMEvent,
LLMRequest,
Message,
LanguageModel,
Model,
ToolCallPart,
ToolDefinition,
ToolResultPart,
@@ -304,9 +304,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: LanguageModel.update(model, {
route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
}),
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
}),
).pipe(
Effect.provide(
@@ -987,7 +985,9 @@ 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(AIError)
expect(error).toBeInstanceOf(LLMError)
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(AIError)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse, Message, ToolDefinition, type LanguageModel } from "../../src"
import { LLM, LLMResponse, Message, ToolDefinition, type Model } 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: LanguageModel
readonly model: Model
}> = [
{
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 { LanguageModel } from "../src"
import type { Model } 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: LanguageModel
readonly model: Model
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 LanguageModel,
type Model,
} 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: LanguageModel
readonly model: Model
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: LanguageModel
readonly model: Model
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: LanguageModel
readonly model: Model
readonly maxTokens?: number
readonly temperature?: number | false
}
+8 -28
View File
@@ -1,21 +1,11 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Schema } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import {
AIError,
ContentPart,
InvalidRequestReason,
LLMEvent,
LLMRequest,
LanguageModel,
ModelID,
ProviderID,
Usage,
} from "../src/schema"
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new LanguageModel({
const model = new Model({
id: ModelID.make("fake-model"),
provider: ProviderID.make("fake-provider"),
route: OpenAIChat.route,
@@ -43,7 +33,7 @@ describe("llm schema", () => {
test("accepts custom route ids", () => {
const decoded = decodeLLMRequest({
model: LanguageModel.update(model, { route: OpenAIResponses.route }),
model: Model.update(model, { route: OpenAIResponses.route }),
system: [],
messages: [],
tools: [],
@@ -61,7 +51,9 @@ 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", () => {
@@ -70,7 +62,7 @@ describe("llm schema", () => {
})
})
describe("AI.Usage", () => {
describe("LLM.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
@@ -96,15 +88,3 @@ describe("AI.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 { AIError } from "../src/schema"
import { LLMError } 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(AIError)
expect(error).toBeInstanceOf(LLMError)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
}),
)
+80 -1
View File
@@ -10,9 +10,15 @@ export interface Mapping {
readonly body?: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
export function map(
packageName: string | undefined,
settings: Readonly<Record<string, unknown>>,
modelID?: string,
): Mapping | undefined {
const baseSettings = mapBaseSettings(settings)
switch (packageName) {
case "@ai-sdk/amazon-bedrock/mantle":
return mapBedrockMantle(settings, baseSettings, modelID)
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
@@ -36,6 +42,79 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
}
}
function mapBedrockMantle(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
modelID: string | undefined,
): Mapping | undefined {
const apiKey =
typeof settings.apiKey === "string"
? settings.apiKey
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials = mapBedrockCredentials(settings)
if (apiKey === undefined && credentials === undefined) return undefined
const safeguard = modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b"
return {
package: `@opencode-ai/ai/providers/amazon-bedrock/mantle/${safeguard ? "chat" : "responses"}`,
settings: {
...baseSettings,
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
...(apiKey === undefined ? {} : { apiKey }),
...(credentials === undefined ? {} : { credentials }),
...(typeof settings.region === "string" ? { region: settings.region } : {}),
...mapOpenAIOptions(settings),
},
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
if (
isRecord(settings.credentials) &&
typeof settings.credentials.region === "string" &&
typeof settings.credentials.accessKeyId === "string" &&
typeof settings.credentials.secretAccessKey === "string"
) {
return {
region: settings.credentials.region,
accessKeyId: settings.credentials.accessKeyId,
secretAccessKey: settings.credentials.secretAccessKey,
...(typeof settings.credentials.sessionToken === "string"
? { sessionToken: settings.credentials.sessionToken }
: {}),
}
}
if (
typeof settings.region !== "string" ||
typeof settings.accessKeyId !== "string" ||
typeof settings.secretAccessKey !== "string"
)
return undefined
return {
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
...(typeof settings.sessionToken === "string" ? { sessionToken: settings.sessionToken } : {}),
}
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { openai: options } }
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
+8 -9
View File
@@ -18,8 +18,8 @@ import {
FinishReason,
InvalidProviderOutputReason,
LLMEvent,
AIError,
LanguageModel,
LLMError,
Model,
ProviderID,
ProviderMetadata,
ToolResultValue,
@@ -182,7 +182,7 @@ export interface Interface {
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
readonly model: (model: Info) => Effect.Effect<Model, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
@@ -338,12 +338,11 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
from: (request) => Effect.succeed(callOptions(request)),
},
with: () => route,
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
return Model.make({
id: info.modelID ?? info.id,
provider: info.providerID,
route,
@@ -556,7 +555,7 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
function streamPartEvents(
state: { step: number; toolNames: Record<string, string> },
event: LanguageModelV3StreamPart,
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
): Effect.Effect<ReadonlyArray<LLMEvent>, LLMError> {
switch (event.type) {
case "stream-start":
case "response-metadata":
@@ -721,10 +720,10 @@ function messageValue(input: unknown) {
function llmError(method: string, error: unknown) {
const reason =
error instanceof AIError
error instanceof LLMError
? new InvalidProviderOutputReason({ message: error.message })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
return new AIError({
return new LLMError({
module: "AISDK",
method,
reason,
+2 -2
View File
@@ -1,6 +1,6 @@
export * as Generate from "./generate"
import { LLM, LLMClient, AIError } from "@opencode-ai/ai"
import { LLM, LLMClient, LLMError } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform"
@@ -57,7 +57,7 @@ export const layer = Layer.effect(
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
(error: LLMError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
+16 -9
View File
@@ -1,7 +1,7 @@
export * as ModelResolver from "./model-resolver"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
@@ -50,7 +50,7 @@ export type Error = VariantUnavailableError | UnsupportedPackageError | Integrat
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: LanguageModel
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: Ref
/** Catalog capabilities used to shape requests before provider lowering. */
@@ -134,14 +134,14 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly loadAISDK?: (model: Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
@@ -175,15 +175,22 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
const configured = {
...resolved.settings,
...credential?.metadata,
...(packageName === "@ai-sdk/amazon-bedrock/mantle"
? nativeCredentialSettings("@opencode-ai/ai/providers/amazon-bedrock/mantle", credential)
: {}),
}
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map(packageName, configured, resolved.modelID ?? resolved.id)
: undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...nativeCredentialSettings(resolved.package ?? "", credential),
...credential?.metadata,
})
})
@@ -207,7 +214,7 @@ export const fromCatalogModel = (
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
+9
View File
@@ -34,6 +34,15 @@ export type ProviderPackage = ProviderPackageDefinition
const packages = new Map<string, Promise<unknown>>()
const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/amazon-bedrock", () => import("@opencode-ai/ai/providers/amazon-bedrock")],
["@opencode-ai/ai/providers/amazon-bedrock/mantle", () => import("@opencode-ai/ai/providers/amazon-bedrock/mantle")],
[
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
() => import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
],
[
"@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
() => import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
],
["@opencode-ai/ai/providers/anthropic", () => import("@opencode-ai/ai/providers/anthropic")],
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
+5 -5
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
@@ -64,7 +64,7 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
@@ -73,7 +73,7 @@ type Dependencies = {
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: LanguageModel
readonly model: Model
readonly cost: Info["cost"]
}
@@ -85,7 +85,7 @@ export type ManualInput = {
type Plan = {
readonly session: SessionSchema.Info
readonly model: LanguageModel
readonly model: Model
readonly cost: Info["cost"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
@@ -282,7 +282,7 @@ const make = (dependencies: Dependencies) => {
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.catchTag("LLM.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
+2 -2
View File
@@ -1,13 +1,13 @@
export * as SessionGenerate from "./generate"
import type { AIError } from "@opencode-ai/ai"
import type { LLMError } from "@opencode-ai/ai"
import { Context, type Effect } from "effect"
import type { Instructions } from "../instructions"
import type { AgentNotFoundError } from "./error"
import type { SessionRunnerModel } from "./runner/model"
import type { SessionSchema } from "./schema"
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | AIError
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError
export interface Interface {
/** Generates text from current Session context without mutating the Session. */
+2 -2
View File
@@ -1,6 +1,6 @@
export * as SessionRunner from "./index"
import type { AIError } from "@opencode-ai/ai"
import type { LLMError } from "@opencode-ai/ai"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
@@ -8,7 +8,7 @@ import { SessionRunnerModel } from "./model"
import type { Instructions } from "../../instructions/index"
export type RunError =
| AIError
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| AgentNotFoundError
+11 -27
View File
@@ -1,13 +1,6 @@
export * as SessionRunnerLLM from "./llm"
import {
LLMClient,
AIError,
LLMEvent,
isContextOverflowFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database"
import { Bus } from "../../bus"
@@ -67,20 +60,16 @@ const classifyToolExits = (
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
settled._tag === "Failure" ? [settled.cause] : exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
// The first non-interrupt, non-decline failure, rebuilt without decline reasons so the
// drain's error channel never carries a decline.
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> => (Cause.isFailReason(reason) ? [] : [reason]),
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
.at(0)
const failure = causes.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap((reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason) ? [] : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
}).at(0)
return {
interrupted: causes.some(Cause.hasInterrupts),
declines,
@@ -367,14 +356,9 @@ const layer = Layer.effect(
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (
llmFailure &&
llmError &&
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.record().outputStarted
) {
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !publisher.record().outputStarted) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* publisher.startAssistant()
+2 -2
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerModel from "./model"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Model } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "../../catalog"
import { ModelResolver } from "../../model-resolver"
@@ -42,7 +42,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (
model: LanguageModel,
model: Model,
options: {
readonly capabilities: Capabilities
readonly variant?: VariantID
+4 -8
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry"
import { AIError } from "@opencode-ai/ai"
import { LLMError } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { Bus } from "../../bus"
@@ -9,12 +9,12 @@ import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: AIError
readonly cause: LLMError
readonly error: SessionError.Error
readonly step: number
}> {}
export function isRetryable(error: AIError) {
export function isRetryable(error: LLMError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
@@ -41,11 +41,7 @@ const retryAfter = (failure: RetryableFailure) => {
return undefined
}
export const schedule = (
bus: Bus.Interface,
sessionID: SessionSchema.ID,
assistantMessageID: () => SessionMessage.ID,
) =>
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID, assistantMessageID: () => SessionMessage.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
+3 -3
View File
@@ -1,6 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
@@ -24,7 +24,7 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
@@ -97,7 +97,7 @@ const make = (dependencies: Dependencies) => {
return Effect.void
}),
Effect.as(true),
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
)
yield* recordUsage
@@ -1,4 +1,4 @@
import { AIError, ToolFailure } from "@opencode-ai/ai"
import { LLMError, ToolFailure } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Permission } from "../permission"
@@ -8,7 +8,7 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
import { SessionRunnerModel } from "./runner/model"
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof AIError) {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return providerError("provider.rate-limit", cause.reason)
@@ -59,7 +59,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
+81
View File
@@ -2,6 +2,87 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps Bedrock Mantle models to their supported native APIs", () => {
const settings = {
bearerToken: "token",
region: "us-west-2",
baseURL: "https://mantle.test/v1",
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
}
expect(AISDKNative.map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
settings: {
apiKey: "token",
baseURL: "https://mantle.test/v1",
region: "us-west-2",
providerOptions: {
openai: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
},
})
expect(AISDKNative.map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
})
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
expect(
AISDKNative.map(
"@ai-sdk/amazon-bedrock/mantle",
{
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
profile: "ignored",
credentialProvider: "ignored",
fetch: "ignored",
store: false,
},
"openai.gpt-oss-120b",
),
).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
settings: {
credentials: {
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
},
region: "eu-west-1",
providerOptions: { openai: { store: false } },
},
})
})
test("keeps Bedrock Mantle on the AI SDK when native static auth is unavailable", () => {
expect(
AISDKNative.map(
"@ai-sdk/amazon-bedrock/mantle",
{ region: "us-east-1", profile: "production" },
"openai.gpt-oss-120b",
),
).toBeUndefined()
})
test("maps the legacy Bedrock endpoint override", () => {
expect(
AISDKNative.map(
"@ai-sdk/amazon-bedrock/mantle",
{ bearerToken: "token", endpoint: "https://mantle.private/v1", region: "us-east-1" },
"openai.gpt-oss-120b",
),
).toMatchObject({ settings: { baseURL: "https://mantle.private/v1" } })
})
test("maps OpenRouter settings to native destinations", () => {
expect(
AISDKNative.map("@openrouter/ai-sdk-provider", {
+2 -2
View File
@@ -2,7 +2,7 @@ import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provide
import { AISDK } from "@opencode-ai/core/aisdk"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { expect } from "bun:test"
@@ -316,7 +316,7 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error).toBeInstanceOf(LLMError)
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
}),
)
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel } from "@opencode-ai/ai"
import { Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AISDK } from "@opencode-ai/core/aisdk"
@@ -17,7 +17,7 @@ const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/mistral"),
})
const runtime = LanguageModel.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
const catalog = Layer.mock(Catalog.Service, {
provider: {
+35 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { LLM, LanguageModel } from "@opencode-ai/ai"
import { LLM, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect"
@@ -42,6 +42,35 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
})
describe("ModelResolver", () => {
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
const responses = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-120b",
settings: { region: "us-east-2" },
}),
credential,
)
const chat = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-safeguard-20b",
settings: { region: "us-east-2" },
}),
credential,
)
expect(responses.route).toMatchObject({
id: "bedrock-mantle-responses",
endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" },
})
expect(chat.route).toMatchObject({
id: "bedrock-mantle-chat",
endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" },
})
}),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -439,7 +468,7 @@ describe("ModelResolver", () => {
body: { custom: true },
limits: { context: 100, output: 20 },
})
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
return Model.make({ id: modelID, provider: "package-provider", route: native.route })
},
})
},
@@ -481,7 +510,7 @@ describe("ModelResolver", () => {
model: (modelID, settings) => {
expect(settings).toMatchObject({ [key]: "oauth-token" })
expect(settings).not.toHaveProperty("apiKey")
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
return Model.make({ id: modelID, provider: "package-provider", route: native.route })
},
}),
}),
@@ -536,7 +565,7 @@ describe("ModelResolver", () => {
limits: { context: 100, output: 20 },
providerOptions,
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
@@ -571,7 +600,7 @@ describe("ModelResolver", () => {
transforms: ["middle-out"],
provider: { sort: "price", only: ["anthropic"] },
})
return LanguageModel.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
return Model.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
},
}),
},
@@ -632,7 +661,7 @@ describe("ModelResolver", () => {
headers: { "x-aisdk": "header" },
body: { custom: true },
})
return LanguageModel.make({
return Model.make({
id: runtime.modelID ?? runtime.id,
provider: runtime.providerID,
route: native.route,
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"
import { GenerationOptions, LLM, LLMRequest, Message, LanguageModel, ToolDefinition } from "@opencode-ai/ai"
import { GenerationOptions, LLM, LLMRequest, Message, Model, ToolDefinition } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
const model = LanguageModel.make({ id: "test", provider: "test", route: OpenAIChat.route })
const model = Model.make({ id: "test", provider: "test", route: OpenAIChat.route })
const tool = ToolDefinition.make({
name: "read",
description: "Read a file",
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -25,7 +25,7 @@ import { DateTime, Effect, Layer, LayerMap, Stream } from "effect"
import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = LanguageModel.make({
const model = Model.make({
id: "summary-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -28,7 +28,7 @@ import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
const model = LanguageModel.make({
const model = Model.make({
id: "summary-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
@@ -145,7 +145,7 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
})
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
session,
model: LanguageModel.make({
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits }),
+3 -3
View File
@@ -4,7 +4,7 @@ import {
ContentPolicyReason,
InvalidProviderOutputReason,
InvalidRequestReason,
AIError,
LLMError,
NoRouteReason,
ModelID,
ProviderID,
@@ -23,10 +23,10 @@ import { Tool } from "@opencode-ai/schema/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
const llm = (reason: AIError["reason"]) => new AIError({ module: "test", method: "stream", reason })
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
describe("toSessionError", () => {
test("maps every AI error reason to the open wire type", () => {
test("maps every LLM reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
message: "rate",
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { AIError, TransportReason } from "@opencode-ai/ai"
import { LLMError, TransportReason } from "@opencode-ai/ai"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -27,7 +27,7 @@ describe("SessionExecution lifecycle", () => {
expect(
SessionExecution.terminal(
Exit.fail(
new AIError({
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
+2 -10
View File
@@ -1,13 +1,5 @@
import { expect } from "bun:test"
import {
LLMClient,
LLMEvent,
LLMResponse,
LanguageModel,
SystemPart,
ToolDefinition,
type LLMRequest,
} from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -54,7 +46,7 @@ const requests: LLMRequest[] = []
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
+20 -18
View File
@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"
import {
AIError,
LLMError,
LLMEvent,
LLMRequest,
Message,
LanguageModel,
Model,
SystemPart,
ToolFailure,
TransportReason,
@@ -130,25 +130,25 @@ const testLLM = TestLLM.layer({
}),
})
const client = TestLLM.clientLayer
const model = LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const defaultSystem = PROMPT_DEFAULT
const replacementModel = LanguageModel.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = LanguageModel.make({
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = Model.make({
id: "compact",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
})
const fullOutputModel = LanguageModel.make({
const fullOutputModel = Model.make({
id: "full-output",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 262_144, output: 262_144 } }),
})
const undersizedContextModel = LanguageModel.make({
const undersizedContextModel = Model.make({
id: "undersized-context",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 1, output: 1_000 } }),
})
const recoveryModel = LanguageModel.make({
const recoveryModel = Model.make({
id: "recovery",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
@@ -230,7 +230,9 @@ const permission = Layer.succeed(
)
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: options ?? tool.options }),
),
)
const echo = Layer.effectDiscard(
Tool.Service.use((registry) =>
@@ -507,21 +509,21 @@ const setup = Effect.gen(function* () {
})
const providerUnavailable = () =>
new AIError({
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
})
const invalidRequest = () =>
new AIError({
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({ message: "Invalid request" }),
})
const rateLimited = (retryAfterMs?: number) =>
new AIError({
new LLMError({
module: "test",
method: "stream",
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
@@ -1305,7 +1307,7 @@ describe("SessionRunnerLLM", () => {
it.effect("uses the selected model family prompt when the agent does not override it", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = LanguageModel.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-provider-prompt"))
@@ -1321,7 +1323,7 @@ describe("SessionRunnerLLM", () => {
it.effect("uses the selected model family prompt when the agent system override is empty", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = LanguageModel.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
const agent = yield* Agent.Service
yield* agent.transform((editor) =>
editor.update(Agent.ID.make("build"), (agent) => {
@@ -2147,7 +2149,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
Stream.fail(
new AIError({
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
@@ -4074,7 +4076,7 @@ describe("SessionRunnerLLM", () => {
it.effect("settles malformed streamed tool input before the provider failure", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = new AIError({
const failure = new LLMError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
@@ -4288,7 +4290,7 @@ describe("SessionRunnerLLM", () => {
it.effect("records malformed provider-executed input as executed", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = new AIError({
const failure = new LLMError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Invalid hosted tool input" }),
@@ -4320,7 +4322,7 @@ describe("SessionRunnerLLM", () => {
it.effect("records a provider failure after malformed input", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = new AIError({
const failure = new LLMError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Provider failed after malformed input" }),
+10 -3
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -24,7 +24,7 @@ import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
const model = LanguageModel.make({
const model = Model.make({
id: "title-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
@@ -77,7 +77,14 @@ const models = Layer.mock(SessionRunnerModel.Service)({
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Agent.node, SessionTitle.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Agent.node,
SessionTitle.node,
]),
[
[llmClient, client],
[SessionRunnerModel.node, models],

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