Compare commits

..

6 Commits

Author SHA1 Message Date
Kit Langton ca7f7c215e refactor(ai): centralize credential lowering 2026-08-19 16:15:45 -04:00
Kit Langton 930541010c fix(ai): preserve provider credential behavior 2026-08-19 14:46:54 -04:00
Kit Langton bb4b9b1ed4 test(core): provide native model credentials 2026-08-19 14:34:59 -04:00
Kit Langton f498cf26eb test(core): isolate provider auth config 2026-08-19 14:18:16 -04:00
Kit Langton 5141e60bf7 fix(ai): preserve credential precedence 2026-08-19 14:14:56 -04:00
Kit Langton 0fda7fe231 refactor(ai): simplify provider boundaries 2026-08-19 14:05:07 -04:00
72 changed files with 1171 additions and 908 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta, v2]
branches: [dev, beta]
paths:
- "bun.lock"
- "package.json"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
"x86_64-linux": "sha256-yYd6nV4YRnTYqf5W5T7oXNo7bSgr1Vzjhy7d5YjA5Vo=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
+58 -5
View File
@@ -71,7 +71,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 `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
const openai = OpenAI.configure({ apiKey, baseURL, store: false })
const model = openai.responses("gpt-4o-mini")
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
@@ -108,17 +108,22 @@ Keep provider facades small and explicit:
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel<Options>` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
const selected = model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: apiKey },
defaults: {},
})
```
@@ -126,6 +131,54 @@ Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
### Folder layout
```
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
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, AIError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
auth.ts Auth type + Auth.bearer / Auth.header / Auth.none
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse
transport/ transport implementations
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing
websocket.ts direct one-request channel executor + raw socket adapter
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/
openai-compatible.ts generic Chat helper + family model helpers
openai-compatible-responses.ts generic Responses helper
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / cloudflare.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed Tool.make helper
tool-runtime.ts narrow one-call typed tool dispatcher
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
### Shared protocol helpers
+64 -14
View File
@@ -34,8 +34,10 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Image, ImageInput } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Image, ImageClient, ImageInput } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { RequestExecutor } from "@opencode-ai/ai/route"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -52,6 +54,10 @@ const program = Effect.gen(function* () {
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
const imageLayer = ImageClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(imageLayer)))
```
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
@@ -199,7 +205,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.
- **`Message.system(...)` / `Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. Top-level `request.system` is the initial prompt; a system message in history is a chronological operator update.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
@@ -305,19 +311,26 @@ const gateway = CloudflareAIGateway.configure({
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
```
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider.
### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error("OPENAI_API_KEY is required")
const selected = model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: apiKey },
defaults: {
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
},
})
```
@@ -341,30 +354,57 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project: "my-project", location: "global" })
model({
id: "gemini-3.5-flash",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
model({
id: "xai/grok-4.20-reasoning",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
model({
id: "claude-sonnet-4-6",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`.
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
## How OpenCode uses this package
OpenCode does not call provider facades directly from the CLI or server. Core owns the integration:
1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter.
2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`.
3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s.
4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package.
Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers.
This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime.
## Provider options & HTTP overlays
@@ -377,6 +417,16 @@ Request options in order of stability:
Route/provider defaults are overridden by request-level values for each axis.
Provider-specific facades accept their own options directly because the provider is already known:
```ts
const model = OpenAI.configure({
apiKey,
store: false,
reasoningEffort: "high",
}).responses("gpt-5")
```
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts
+6 -8
View File
@@ -17,13 +17,11 @@ import { OpenAI } from "@opencode-ai/ai/providers"
const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol
// choice, capabilities, deployment options, authentication, and defaults.
// choice, deployment options, authentication, and defaults. Catalog capabilities
// remain application-owned and are not part of LanguageModel.
const model = OpenAI.configure({
apiKey,
generation: { maxTokens: 160 },
providerOptions: {
store: false,
},
}).model("gpt-4o-mini")
// 2. Build a provider-neutral request. This is useful when reusing one request
@@ -74,8 +72,8 @@ const streamText = LLM.stream(request).pipe(
Stream.runDrain,
)
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
// advertise definitions on the request, stream one turn, dispatch local calls,
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
// advertise definitions on the request, stream one call, dispatch local calls,
// then persist/build follow-up history in the enclosing product flow.
const tools = {
get_weather: Tool.make({
@@ -102,7 +100,7 @@ const streamWithTools = Effect.gen(function* () {
console.log("tool result", event.name, dispatched.result)
// A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead.
// model call. This tutorial keeps the boundary visible instead.
const followUp = LLMRequest.update(request, {
messages: [
...request.messages,
@@ -195,7 +193,7 @@ const FakeAdapter = Route.make({
provider: "fake-echo",
protocol: FakeProtocol,
endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
auth: Auth.passthrough,
auth: Auth.none,
framing: Framing.sse,
})
+3
View File
@@ -37,6 +37,9 @@ export type {
LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider.js"
export type {
Credential as ProviderPackageCredential,
Defaults as ProviderPackageDefaults,
Definition as ProviderPackageDefinition,
ModelInput as ProviderPackageModelInput,
Settings as ProviderPackageSettings,
} from "./provider-package.js"
@@ -1,19 +1,5 @@
import { Option, Schema } from "effect"
import type { LLMRequest } from "../../schema/index.js"
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
export const TextVerbosity = Schema.declare<TextVerbosity>(
(value): value is TextVerbosity => typeof value === "string",
{ title: "TextVerbosity" },
)
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
export const ResponseIncludables = [
"file_search_call.results",
@@ -33,6 +19,7 @@ export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
@@ -1,9 +1,8 @@
import { ReasoningEfforts } from "../../schema/index.js"
import { OpenResponsesOptions } from "./open-responses-options.js"
export const OpenAIReasoningEfforts = OpenResponsesOptions.ReasoningEfforts
export type OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosities = OpenResponsesOptions.TextVerbosities
export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
@@ -13,7 +12,7 @@ export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
+42 -3
View File
@@ -1,7 +1,21 @@
import { Auth } from "./route/auth.js"
import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js"
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
export interface Settings {}
export type Credential =
| {
readonly type: "key"
readonly value: string
readonly configuration?: Readonly<Record<string, unknown>>
}
| {
readonly type: "oauth"
readonly accessToken: string
}
export interface Defaults {
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
@@ -11,11 +25,36 @@ export interface Settings extends Readonly<Record<string, unknown>> {
}
}
export interface ModelInput<ProviderSettings extends Settings = Settings> {
readonly id: string
readonly settings: ProviderSettings
readonly credential?: Credential
readonly defaults: Defaults
}
export const routeDefaults = (input: Defaults) => ({
headers: input.headers,
http: input.body === undefined ? undefined : { body: input.body },
limits: input.limits,
})
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
export const bearerAuthOption = (input: Credential): AuthOverride => ({ auth: Auth.bearer(bearerCredentialValue(input)) })
export const apiKeyOrBearerAuthOption = (
input: Credential,
competingKeyHeader: string,
): RequiredApiKeyAuth | AuthOverride =>
input.type === "key"
? { apiKey: input.value }
: { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) }
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
}
export * as ProviderPackage from "./provider-package.js"
@@ -1,6 +1,6 @@
import { Auth } from "../route/auth.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
@@ -79,29 +79,27 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
if (!input.credential && input.settings.auth === "sigv4" && input.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,
...ProviderPackage.routeDefaults(input.defaults),
apiKey: input.credential
? ProviderPackage.bearerCredentialValue(input.credential)
: input.settings.auth === "sigv4"
? undefined
: input.settings.apiKey,
baseURL: input.settings.baseURL,
credentials: input.settings.credentials,
providerOptions: input.settings.providerOptions,
region: input.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 chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).responses(input.id)
export const model = chatModel
+15 -13
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
@@ -50,19 +50,21 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
region: settings.region,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
apiKey: input.credential
? ProviderPackage.bearerCredentialValue(input.credential)
: input.settings.auth === "sigv4"
? undefined
: input.settings.apiKey,
baseURL: input.settings.baseURL,
credentials: input.settings.credentials,
generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP },
region: input.settings.region,
}).model(input.id)
}
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
@@ -32,7 +32,9 @@ export const routes = [AnthropicMessages.route]
const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
)
}
export const configure = (input: Config) => {
@@ -57,21 +59,20 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
: input.settings.authToken === undefined
? { apiKey: input.settings.apiKey }
: { auth: Auth.bearer(input.settings.authToken) }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
export * as AnthropicCompatible from "./anthropic-compatible.js"
+17 -16
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -31,9 +31,11 @@ export type Settings = ProviderPackage.Settings &
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key")),
)
}
export const configure = (input: Config = {}) => {
@@ -52,18 +54,17 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
: input.settings.authToken === undefined
? { apiKey: input.settings.apiKey }
: { auth: Auth.bearer(input.settings.authToken) }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
+17 -16
View File
@@ -1,7 +1,7 @@
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
@@ -120,28 +120,29 @@ export const provider = {
configure,
}
const config = (settings: Settings): Config => {
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
const settings = input.settings
const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined
const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined)
const resourceName =
settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined)
const common = {
apiKey: settings.apiKey,
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
: { apiKey: settings.apiKey }),
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
if (baseURL !== undefined) return { ...common, baseURL }
if (resourceName !== undefined) return { ...common, resourceName }
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).responses(input.id)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const model = responsesModel
+11 -12
View File
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
@@ -68,16 +68,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -1,5 +1,5 @@
import { Effect, Schema, Struct } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
@@ -100,19 +100,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
@@ -70,19 +70,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -69,9 +69,8 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth")
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
return Auth.remove("x-goog-api-key").andThen(auth)
}
export * as GoogleVertexShared from "./google-vertex-shared.js"
+20 -13
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { Gemini } from "../protocols/gemini.js"
import { ProviderShared } from "../protocols/shared.js"
import { Auth } from "../route/auth.js"
@@ -94,7 +94,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
return route.with({
...rest,
endpoint: { baseURL: endpoint },
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
auth:
apiKey === undefined
? GoogleVertexShared.oauth(input, project)
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
})
}
@@ -111,17 +114,21 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? input.credential.type === "key"
? { apiKey: input.credential.value }
: { accessToken: input.credential.accessToken }
: input.settings.apiKey === undefined
? { accessToken: input.settings.accessToken }
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
+14 -12
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
import { Gemini } from "../protocols/gemini.js"
import { GoogleImages } from "../protocols/google-images.js"
@@ -28,9 +28,11 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key")),
)
}
const configuredRoute = (input: Config) => {
@@ -57,14 +59,14 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key")
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const image = provider.image
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
@@ -46,16 +46,13 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+10 -10
View File
@@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -68,16 +68,16 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
+5 -22
View File
@@ -1,37 +1,21 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIConfigOptions = Options
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
const definedEntries = (input: Record<string, unknown>) =>
Object.entries(input).filter((entry) => entry[1] !== undefined)
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
const result = Object.fromEntries(
definedEntries({
store: options?.store,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
include: options?.include,
textVerbosity: options?.textVerbosity,
serviceTier: options?.serviceTier,
}),
)
if (Object.keys(result).length === 0) return undefined
return result
}
export const gpt5DefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => {
const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return openAIProviderOptions({
return {
reasoningEffort: "medium",
reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by
@@ -44,14 +28,13 @@ export const gpt5DefaultOptions = (
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
: undefined,
})
}
}
export const openAIDefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
+53 -19
View File
@@ -1,10 +1,10 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
@@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization.
export type Config = RouteDefaultsInput &
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
OpenAIConfigOptions &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface ImageGenerationOptions {
@@ -57,13 +57,12 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
},
})
export interface Settings extends ProviderPackage.Settings {
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -73,6 +72,39 @@ const defaults = (input: Config) => {
return rest
}
const splitConfigOptions = <Input extends OpenAIConfigOptions>(input: Input) => {
const {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
...rest
} = input
return {
options: {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
},
rest,
}
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
@@ -82,7 +114,8 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const split = splitConfigOptions(defaults(input))
const modelDefaults = { ...split.rest, providerOptions: split.options }
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
@@ -113,31 +146,32 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
const settings = input.settings
const options = splitConfigOptions(settings).options
const headers = {
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...settings.headers,
...input.defaults.headers,
}
return {
apiKey: settings.apiKey,
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
...options,
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
return configure(config(input)).responses(input.id)
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+9 -12
View File
@@ -5,7 +5,7 @@ import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
@@ -191,15 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+9 -9
View File
@@ -8,7 +8,7 @@ import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { XAIImages } from "../protocols/xai-images.js"
import type { OpenAIOptionsInput } from "./openai-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
@@ -95,15 +95,15 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+1 -2
View File
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, RouteID } from "./ids.js"
import { ProviderMetadata } from "./messages.js"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids.js"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
+2 -15
View File
@@ -1,21 +1,8 @@
import { Schema } from "effect"
import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
ProviderMetadata,
ToolCallPart,
ToolOutput,
ToolResultPart,
ToolResultValue,
type ContentPart,
} from "./messages.js"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids.js"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages.js"
import { ProviderFailureClassification } from "./errors.js"
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export { ProviderMetadata } from "./messages.js"
/**
* Token usage reported by an LLM provider.
*
+20
View File
@@ -1,4 +1,8 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata }
/** Stable string identifier for a protocol implementation. */
export const ProtocolID = Schema.String
@@ -22,3 +26,19 @@ export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.String
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
+1 -9
View File
@@ -1,24 +1,16 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids.js"
import {
CacheHint,
CachePolicy,
GenerationOptions,
HttpOptions,
JsonSchema,
LanguageModelSchema,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "LLM.ProviderMetadata",
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
const systemPartSchema = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
+1 -4
View File
@@ -1,11 +1,8 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import { JsonSchema, ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
export const mergeJsonRecords = (
...items: ReadonlyArray<Record<string, unknown> | undefined>
): Record<string, unknown> | undefined => {
+55 -17
View File
@@ -81,8 +81,22 @@ OpenAI.configure({
}).responses("gpt-4.1-mini")
OpenAI.configure({
generation: { maxTokens: 100 },
providerOptions: { store: false },
store: false,
}).responses("gpt-4.1-mini")
OpenAI.model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: "sk-test" },
defaults: { headers: { "x-test": "value" } },
})
OpenAI.model({
id: "gpt-5",
settings: {
// @ts-expect-error Common request defaults belong under input.defaults.
headers: { "x-test": "value" },
},
defaults: {},
})
// @ts-expect-error OpenAI model selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
@@ -97,7 +111,7 @@ OpenAI.configure({ bogus: true })
OpenAI.configure({ generation: { maxTokens: "many" } })
// @ts-expect-error provider-native options remain typed.
OpenAI.configure({ providerOptions: { store: "false" } })
OpenAI.configure({ store: "false" })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
@@ -145,8 +159,12 @@ Anthropic.configure({
}).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
Anthropic.model({
id: "claude-sonnet-4-6",
// @ts-expect-error Anthropic package settings accept only one auth source.
settings: { apiKey: "anthropic-key", authToken: "anthropic-token" },
defaults: {},
})
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
// @ts-expect-error Anthropic thinking budgets must be numbers.
@@ -162,11 +180,15 @@ AnthropicCompatible.configure({
AnthropicCompatible.configure({ apiKey: "messages-key" })
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
AnthropicCompatible.model("compatible-model", {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
AnthropicCompatible.model({
id: "compatible-model",
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
settings: {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
},
defaults: {},
})
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
@@ -189,15 +211,23 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
// @ts-expect-error Vertex Gemini config accepts only one auth source.
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertex.model({
id: "gemini-3.5-flash",
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
)
// @ts-expect-error Vertex Chat package settings do not accept API keys.
GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat model selectors only accept model ids.
@@ -214,8 +244,12 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"xai/grok-4.20-reasoning",
)
// @ts-expect-error Vertex Responses package settings do not accept API keys.
GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
GoogleVertexResponses.model({
id: "xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
"xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses model selectors only accept model ids.
@@ -233,8 +267,12 @@ GoogleVertexMessages.configure({
project: "project",
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
}).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.model({
id: "claude-sonnet-4-6",
// @ts-expect-error Vertex Messages package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6",
+19 -13
View File
@@ -17,25 +17,31 @@ describe("request option precedence", () => {
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions(
{
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
openai: {
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
},
},
{
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
openai: {
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
},
},
{ metadata: { request: true }, primitive: false },
{ openai: { metadata: { request: true }, primitive: false } },
)
expect(merged).toEqual({
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
openai: {
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
},
})
})
@@ -4,11 +4,10 @@ import { GoogleVertexResponses } from "../../src/providers.js"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity must be a string.
providerOptions: { textVerbosity: 1 },
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
providerOptions: { textVerbosity: "verbose" },
})
@@ -4,10 +4,6 @@ import { OpenAICompatibleResponses } from "../../src/providers.js"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningSummary: "detailed" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({
model,
@@ -2,14 +2,8 @@ import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
const selected = OpenAI.responses("gpt-5")
const chat = OpenAI.chat("gpt-4o-mini")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model: selected,
@@ -4,7 +4,6 @@ import { XAI } from "../../src/providers.js"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model,
+315 -103
View File
@@ -1,6 +1,72 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { LLM, ProviderPackage } from "@opencode-ai/ai"
import { model } from "@opencode-ai/ai/providers/openai"
const packageInput = <Input extends Record<string, unknown>>(id: string, input: Input) => {
const { headers, body, limits, ...settings } = input
return { id, settings, defaults: { headers, body, limits } }
}
const authHeaders = (
selected: ReturnType<typeof model>,
headers: Record<string, string> = {},
env: Record<string, string> = {},
) =>
Effect.runPromise(
selected.route.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
)
const applyAuth = (
option: ReturnType<typeof ProviderPackage.bearerAuthOption>,
headers: Record<string, string> = {},
) => {
const selected = model(packageInput("gpt-5", { apiKey: "fixture" }))
return Effect.runPromise(
option.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}),
)
}
describe("provider package credential lowering", () => {
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
const oauth = await applyAuth(
ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }),
)
expect(key.authorization).toBe("Bearer provider-key")
expect(oauth.authorization).toBe("Bearer provider-token")
})
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
expect(
ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key"),
).toEqual({ apiKey: "provider-key" })
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
{ type: "oauth", accessToken: "provider-token" },
"x-api-key",
)
if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth")
const headers = await applyAuth(oauth, { "x-api-key": "stale" })
expect(headers.authorization).toBe("Bearer provider-token")
expect(headers["x-api-key"]).toBeUndefined()
})
})
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -45,14 +111,18 @@ describe("provider package entrypoints", () => {
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
})
const openrouter = OpenRouter.model(
packageInput("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
}),
)
const xai = XAI.model(
packageInput("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
}),
)
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
@@ -65,32 +135,151 @@ describe("provider package entrypoints", () => {
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
unrelatedInheritedSetting: true,
})
const selected = model(
packageInput("gpt-5", {
apiKey: "fixture",
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
reasoningEffort: "high",
unrelatedInheritedSetting: true,
}),
)
expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
})
test("lets provider packages interpret resolved credentials", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const Azure = await import("@opencode-ai/ai/providers/azure")
const Google = await import("@opencode-ai/ai/providers/google")
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const openai = model({
id: "gpt-5",
settings: {},
credential: { type: "oauth", accessToken: "openai-token" },
defaults: {},
})
const anthropicKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "anthropic-key" },
defaults: {},
})
const anthropicOAuth = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "oauth", accessToken: "anthropic-token" },
defaults: {},
})
const anthropicEmptyKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "" },
defaults: {},
})
const azureKey = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "key", value: "azure-key" },
defaults: {},
})
const azureOAuth = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "oauth", accessToken: "azure-token" },
defaults: {},
})
const googleKey = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "key", value: "google-key" },
defaults: {},
})
const googleOAuth = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "oauth", accessToken: "google-token" },
defaults: {},
})
const vertexKey = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: {},
credential: { type: "key", value: "vertex-key" },
defaults: {},
})
const vertexOAuth = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: { project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-token" },
defaults: {},
})
const vertexChatOAuth = GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { apiKey: "configured-key", project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-chat-token" },
defaults: {},
})
expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token")
const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" })
const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" })
const anthropicEmptyKeyHeaders = await authHeaders(
anthropicEmptyKey,
{ authorization: "Bearer stale" },
{ ANTHROPIC_API_KEY: "environment-key" },
)
const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" })
const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" })
const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" })
const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" })
const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" })
const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" })
expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key")
expect(anthropicKeyHeaders.authorization).toBeUndefined()
expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token")
expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined()
expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key")
expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined()
expect(azureKeyHeaders["api-key"]).toBe("azure-key")
expect(azureKeyHeaders.authorization).toBeUndefined()
expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token")
expect(azureOAuthHeaders["api-key"]).toBeUndefined()
expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key")
expect(googleKeyHeaders.authorization).toBeUndefined()
expect(googleOAuthHeaders.authorization).toBe("Bearer google-token")
expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key")
expect(vertexKeyHeaders.authorization).toBeUndefined()
expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token")
expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token")
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model("custom-model", {
apiKey: "fixture",
baseURL: "https://responses.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
})
const selected = OpenAICompatibleResponses.model(
packageInput("custom-model", {
apiKey: "fixture",
baseURL: "https://responses.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
}),
)
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("openai-compatible-responses")
@@ -106,15 +295,17 @@ describe("provider package entrypoints", () => {
test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
})
const selected = AnthropicCompatible.model(
packageInput("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
}),
)
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
@@ -130,10 +321,12 @@ describe("provider package entrypoints", () => {
test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
})
const selected = Anthropic.model(
packageInput("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
}),
)
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
})
@@ -141,7 +334,7 @@ describe("provider package entrypoints", () => {
test("requires an Anthropic-compatible base URL at runtime", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]),
).toThrow("Anthropic-compatible providers require a baseURL")
})
@@ -150,25 +343,28 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, [
"compatible-model",
{
packageInput("compatible-model", {
apiKey: "fixture",
authToken: "token",
baseURL: "https://messages.example.test/v1",
},
}),
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
Reflect.apply(Anthropic.model, undefined, [
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
organization: "org_123",
project: "proj_123",
})
const selected = model(
packageInput("gpt-5", {
apiKey: "fixture",
organization: "org_123",
project: "proj_123",
}),
)
expect(selected.route.defaults.headers).toMatchObject({
"OpenAI-Organization": "org_123",
@@ -188,10 +384,10 @@ describe("provider package entrypoints", () => {
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
const chat = AzureChat.model("deployment", settings)
const responses = AzureResponses.model(packageInput("deployment", settings))
const chat = AzureChat.model(packageInput("deployment", settings))
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
expect(responses.route.id).toBe("azure-openai-responses")
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
@@ -202,16 +398,20 @@ describe("provider package entrypoints", () => {
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
const deployment = Azure.model(
packageInput("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
}),
)
const gateway = Azure.model(
packageInput("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
}),
)
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
@@ -223,14 +423,16 @@ describe("provider package entrypoints", () => {
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
})
const selected = Google.model(
packageInput("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
}),
)
expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
@@ -246,27 +448,35 @@ describe("provider package entrypoints", () => {
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
const gemini = GoogleVertex.model("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
})
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const gemini = GoogleVertex.model(
packageInput("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
}),
)
const messages = GoogleVertexMessages.model(
packageInput("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
const chat = GoogleVertexChat.model(
packageInput("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
const responses = GoogleVertexResponses.model(
packageInput("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini")
@@ -276,11 +486,13 @@ describe("provider package entrypoints", () => {
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(
GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}).route.endpoint.baseURL,
GoogleVertex.model(
packageInput("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}),
).route.endpoint.baseURL,
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
expect(messages.route.id).toBe("google-vertex-messages")
expect(messages.route.protocol).toBe("anthropic-messages")
@@ -310,8 +522,11 @@ describe("provider package entrypoints", () => {
const Providers = await import("@opencode-ai/ai/providers")
expect(() =>
Reflect.apply(GoogleVertex.model, undefined, [
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
packageInput("gemini-3.5-flash", {
accessToken: "token",
apiKey: "fixture",
project: "vertex-project",
}),
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
@@ -320,8 +535,7 @@ describe("provider package entrypoints", () => {
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
@@ -331,8 +545,7 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
@@ -342,8 +555,7 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
@@ -255,7 +255,7 @@ describe("OpenAI Chat route", () => {
LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/",
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).chat("gpt-4o-mini"),
@@ -178,16 +178,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through custom OpenAI text verbosity strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { textVerbosity: "verbose" } }),
)
expect(prepared.body.text).toEqual({ verbosity: "verbose" })
}),
)
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
-12
View File
@@ -105,16 +105,4 @@ describe("extractPromptFromMessage", () => {
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
test("restores command invocation text", () => {
const message = {
id: "msg_1",
type: "user",
text: "expanded command template",
command: { name: "command", arguments: "input" },
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" })
})
})
+1 -3
View File
@@ -44,9 +44,7 @@ export function extractPromptFromMessage(
message: SessionMessageUser,
opts?: { directory?: string; attachmentName?: string },
): Prompt {
const text = message.command
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
: (readPromptPresentation(message.metadata)?.displayText ?? message.text)
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
@@ -50,18 +50,6 @@ describe("session message presentation", () => {
})
})
test("projects command invocation text", () => {
const message = {
id: "msg_user",
type: "user",
text: "expanded command template",
command: { name: "command", arguments: "input" },
time: { created: 1 },
} satisfies SessionMessageUser
expect(presentUserParts("ses_1", message)[0]).toMatchObject({ type: "text", text: "/command input" })
})
test("projects current assistant content for existing DOM tools", () => {
const message = {
id: "msg_assistant",
+1 -3
View File
@@ -57,9 +57,7 @@ export function presentUserMessage(
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
const presentation = readPromptPresentation(message.metadata)
const text = message.command
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
: (presentation?.displayText ?? message.text)
const text = presentation?.displayText ?? message.text
return [
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
...(message.files ?? []).map(
@@ -30,8 +30,6 @@ export type FileDiffInfo = {
status: "added" | "deleted" | "modified"
}
export type PromptCommandInvocation = { name: string; arguments: string }
export type PromptBase64 = string
export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
@@ -1686,7 +1684,6 @@ export type SessionMessageUser = {
metadata?: { [x: string]: JsonValue }
time: { created: number }
text: string
command?: PromptCommandInvocation
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
@@ -1695,7 +1692,6 @@ export type SessionMessageUser = {
export type SessionInboxUserPayload = {
text: string
command?: PromptCommandInvocation
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
@@ -1704,7 +1700,6 @@ export type SessionInboxUserPayload = {
export type SessionInboxUserPayload1 = {
text: string
command?: PromptCommandInvocation
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
@@ -2557,7 +2552,6 @@ export type SessionImportInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly command?: { readonly name: string; readonly arguments: string }
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
@@ -2827,7 +2821,6 @@ export type SessionImportInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly command?: { readonly name: string; readonly arguments: string }
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
@@ -3097,7 +3090,6 @@ export type SessionImportInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly command?: { readonly name: string; readonly arguments: string }
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
+48 -40
View File
@@ -20,16 +20,48 @@ export interface MapInput {
export function map(input: MapInput): Mapping | undefined {
const baseSettings = mapBaseSettings(input.settings)
switch (input.packageName) {
case "@ai-sdk/anthropic":
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...openAIOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/anthropic": {
const providerOptions = {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
}
return {
package: "@opencode-ai/ai/providers/anthropic",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
}
case "@ai-sdk/openai-compatible":
return typeof input.settings.baseURL !== "string"
? undefined
: {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapOpenAIOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/amazon-bedrock":
return {
package: "@opencode-ai/ai/providers/amazon-bedrock",
@@ -71,10 +103,7 @@ export function map(input: MapInput): Mapping | undefined {
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
...mapGoogleOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
@@ -97,29 +126,6 @@ export function map(input: MapInput): Mapping | undefined {
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "organization", "project", "queryParams"]),
},
}
case "@ai-sdk/openai-compatible":
if (typeof input.settings.baseURL !== "string") return
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapProviderOptions(input.settings, ["apiKey", "baseURL"]),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -134,12 +140,6 @@ export function map(input: MapInput): Mapping | undefined {
}
}
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
@@ -260,17 +260,26 @@ function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = openAIOptions(settings)
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function openAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.instructions === "string" ? { instructions: settings.instructions } : {}),
...(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 } : {}),
...(typeof settings.truncation === "string" ? { truncation: settings.truncation } : {}),
...(isRecord(settings.allowedTools) ? { allowedTools: settings.allowedTools } : {}),
...(typeof settings.maxToolCalls === "number" ? { maxToolCalls: settings.maxToolCalls } : {}),
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
return options
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
@@ -283,7 +292,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -298,7 +307,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
+25 -25
View File
@@ -1,7 +1,7 @@
export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { LanguageModel, ProviderPackage } from "@opencode-ai/ai"
import { Auth } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
@@ -124,7 +124,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const configuration = credential?.type === "key" ? credential.configuration : undefined
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const configured = Provider.mergeOverlay(resolved.settings, configuration) ?? {}
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
@@ -140,7 +140,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...legacyCredentialSettings(credential),
...credential?.metadata,
...configuration,
}) ?? {},
@@ -157,16 +157,18 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
const runtime = module.model({
id: resolved.modelID ?? resolved.id,
settings: mapped,
credential: providerCredential(credential),
defaults: {
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
},
})
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
@@ -225,25 +227,23 @@ function unresolvedProviderVariables(model: Info, baseURL: string) {
})
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
const legacyCredentialSettings = (credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
const providerCredential = (credential: Credential.Value | undefined): ProviderPackage.Credential | undefined => {
if (!credential) return undefined
if (credential.type === "key" && credential.key.length === 0) return undefined
if (credential.type === "oauth" && credential.access.length === 0) return undefined
if (credential.type === "key")
return {
type: "key",
value: credential.key,
configuration: credential.configuration,
}
return { type: "oauth", accessToken: credential.access }
}
const unsupported = (model: Info) =>
+1 -1
View File
@@ -171,7 +171,7 @@ const importPackage = Effect.fn("Provider.importPackage")(function* (
if (typeof module !== "object" || module === null || typeof (module as { model?: unknown }).model !== "function") {
return yield* new LoadError({
package: specifier,
cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`),
cause: new Error(`Provider package ${specifier} does not export model(input)`),
})
}
return module as ProviderPackageDefinition
+7 -11
View File
@@ -222,7 +222,6 @@ export interface Interface {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
text: string
command?: Prompt["command"]
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
@@ -587,7 +586,11 @@ const layer = Layer.effect(
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(input, image, skills).pipe(Effect.provideService(FSUtil.Service, fs))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
skills,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
type: "user",
@@ -654,7 +657,6 @@ const layer = Layer.effect(
id: input.id,
sessionID: input.sessionID,
text: evaluated.text,
command: { name: input.command, arguments: input.arguments ?? "" },
files: input.files,
agents: input.agents,
skills: input.skills,
@@ -962,7 +964,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt & Pick<Prompt, "command">,
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface>,
) {
@@ -985,13 +987,7 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
})
})
})
return Prompt.fromUserMessage({
text: input.text,
command: input.command,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+11 -9
View File
@@ -20,7 +20,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Project } from "@opencode-ai/schema/project"
import { Prompt } from "@opencode-ai/schema/prompt"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
@@ -527,14 +526,17 @@ const layer = Layer.effectDiscard(
yield* insertMessage(
db,
event,
input.type === "user"
? {
...Prompt.fromUserMessage(input.payload),
id: input.id,
type: "user",
metadata: input.payload.metadata,
time: { created: DateTime.makeUnsafe(event.created) },
}
input.type === "user"
? {
id: input.id,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
skills: input.payload.skills,
time: { created: DateTime.makeUnsafe(event.created) },
}
: {
id: input.id,
type: "synthetic",
+5 -7
View File
@@ -22,14 +22,12 @@ describe("AISDKNative", () => {
settings: {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
providerOptions: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
},
organization: "org",
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
instructions: "Follow the repository instructions.",
truncation: "auto",
},
})
expect(map("@ai-sdk/openai-compatible", { baseURL: "https://example.com/v1", reasoningEffort: "high" })).toEqual({
+2 -11
View File
@@ -94,19 +94,10 @@ it.effect("projects request settings, headers, and body overlays", () =>
headers: { "x-test": "header" },
body: { safety_setting: "strict" },
})
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
prompt: "Hello",
providerOptions: { safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }] },
}),
)
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.providerOptions).toEqual({
google: {
thinkingConfig: { thinkingBudget: 1024 },
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
},
google: { thinkingConfig: { thinkingBudget: 1024 } },
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
+161 -69
View File
@@ -66,10 +66,6 @@ function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect:
)
}
function withConfigEnv<A, E, R>(env: Record<string, string>, effect: () => Effect.Effect<A, E, R>) {
return effect().pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))))
}
describe("ModelResolver", () => {
it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () =>
Effect.gen(function* () {
@@ -81,6 +77,22 @@ describe("ModelResolver", () => {
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
const configuredCredential = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
modelID: "configured-deployment",
settings: { resourceName: "catalog-resource", apiVersion: "catalog-version" },
}),
Credential.Key.make({
type: "key",
key: "secret",
configuration: {
resourceName: "configured-resource",
apiVersion: "configured-version",
useDeploymentBasedUrls: true,
},
}),
)
const chat = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
@@ -118,6 +130,10 @@ describe("ModelResolver", () => {
query: { "api-version": "2025-01-01-preview" },
},
})
expect(configuredCredential.route.endpoint).toMatchObject({
baseURL: "https://configured-resource.openai.azure.com/openai/deployments/configured-deployment",
query: { "api-version": "configured-version" },
})
expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" })
expect(chat.route.id).toBe("azure-openai-chat")
expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" })
@@ -139,11 +155,22 @@ describe("ModelResolver", () => {
settings: { baseURL: "https://${AZURE_HOST}/openai" },
}),
)
const configured = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
}),
Credential.Key.make({
type: "key",
key: "secret",
configuration: { baseURL: "https://${AZURE_HOST}/openai" },
}),
)
expect(resolved.route.endpoint).toMatchObject({
baseURL: "https://resource.openai.azure.com/openai/v1",
query: { "api-version": "v1" },
})
expect(configured.route.endpoint.baseURL).toBe("https://resource.openai.azure.com/openai/v1")
}),
),
)
@@ -260,25 +287,33 @@ describe("ModelResolver", () => {
}),
)
it.effect("treats an empty configured API key as omitted", () =>
withConfigEnv({ OPENAI_API_KEY: "environment-key" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
it.effect("treats empty configured and selected API keys as omitted", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const selected = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
Credential.Key.make({ type: "key", key: "" }),
)
const headers = yield* Effect.forEach([resolved, selected], (model) =>
model.route.auth.apply({
request: LLM.request({ model, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
}),
).pipe(
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OPENAI_API_KEY: "environment-key" } }))),
)
expect(headers.authorization).toBe("Bearer environment-key")
}),
),
expect(headers.map((item) => item.authorization)).toEqual(["Bearer environment-key", "Bearer environment-key"])
}),
)
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
@@ -341,7 +376,7 @@ describe("ModelResolver", () => {
})
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
return withConfigEnv({}, () =>
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(selected)
@@ -362,7 +397,7 @@ describe("ModelResolver", () => {
})
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
withConfigEnv({}, () =>
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
@@ -588,6 +623,43 @@ describe("ModelResolver", () => {
}),
)
it.effect("lets the native Anthropic package distinguish key and OAuth credentials", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
})
const key = yield* ModelResolver.fromCatalogModel(
catalog,
Credential.Key.make({ type: "key", key: "anthropic-key" }),
)
const oauth = yield* ModelResolver.fromCatalogModel(
catalog,
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "anthropic-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
)
const input = (resolved: LanguageModel) => ({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST" as const,
url: "https://anthropic.example/v1/messages",
body: "{}",
headers: Headers.empty,
})
const keyHeaders = yield* key.route.auth.apply(input(key))
const oauthHeaders = yield* oauth.route.auth.apply(input(oauth))
expect(keyHeaders["x-api-key"]).toBe("anthropic-key")
expect(keyHeaders.authorization).toBeUndefined()
expect(oauthHeaders.authorization).toBe("Bearer anthropic-token")
expect(oauthHeaders["x-api-key"]).toBeUndefined()
}),
)
it.effect("uses resolved credentials for bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -692,6 +764,24 @@ describe("ModelResolver", () => {
}),
)
it.effect("maps flat native OpenAI settings into provider options", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
modelID: "gpt-5",
settings: { reasoningEffort: "high", store: true },
}),
)
expect(resolved.route.defaults.providerOptions).toEqual({
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
}),
)
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -778,15 +868,18 @@ describe("ModelResolver", () => {
loadPackage: (specifier) => {
expect(specifier).toBe("@opencode-ai/ai/providers/custom")
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("api-test-model")
expect(settings).toEqual({
region: "test",
headers: { "x-package": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
model: (input) => {
expect(input).toEqual({
id: "api-test-model",
settings: { region: "test" },
credential: undefined,
defaults: {
headers: { "x-package": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
},
})
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route })
},
})
},
@@ -797,7 +890,7 @@ describe("ModelResolver", () => {
}),
)
it.effect("maps OAuth credentials to native provider auth settings", () =>
it.effect("passes OAuth credentials to native provider packages without interpreting them", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai"), {
@@ -812,23 +905,23 @@ describe("ModelResolver", () => {
expires: Date.now() + 60_000,
})
const packages = [
["@opencode-ai/ai/providers/google-vertex", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"],
["@opencode-ai/ai/providers/anthropic", "authToken"],
["@opencode-ai/ai/providers/anthropic-compatible", "authToken"],
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
"@opencode-ai/ai/providers/google-vertex/chat",
"@opencode-ai/ai/providers/google-vertex/responses",
"@opencode-ai/ai/providers/google-vertex/messages",
"@opencode-ai/ai/providers/anthropic",
"@opencode-ai/ai/providers/anthropic-compatible",
] as const
yield* Effect.forEach(packages, ([specifier, key]) =>
yield* Effect.forEach(packages, (specifier) =>
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
loadPackage: () =>
Effect.succeed({
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 })
model: (input) => {
expect(input.settings).toEqual({ apiKey: "configured-key" })
expect(input.credential).toEqual({ type: "oauth", accessToken: "oauth-token" })
return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route })
},
}),
}),
@@ -858,41 +951,41 @@ describe("ModelResolver", () => {
"@ai-sdk/anthropic",
"@opencode-ai/ai/providers/anthropic",
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
{ providerOptions: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
],
[
"@ai-sdk/openai-compatible",
"@opencode-ai/ai/providers/openai-compatible",
{ reasoningEffort: "high" },
{ reasoningEffort: "high" },
{ provider: "test-provider", providerOptions: { reasoningEffort: "high" } },
],
[
"@ai-sdk/google",
"@opencode-ai/ai/providers/google",
{ thinkingConfig: { thinkingLevel: "high" } },
{ thinkingConfig: { thinkingLevel: "high" } },
{ providerOptions: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@ai-sdk/google-vertex",
"@opencode-ai/ai/providers/google-vertex",
{ thinkingConfig: { thinkingLevel: "high" } },
{ thinkingConfig: { thinkingLevel: "high" } },
{ providerOptions: { thinkingConfig: { thinkingLevel: "high" } } },
],
[
"@openrouter/ai-sdk-provider",
"@opencode-ai/ai/providers/openrouter",
{ reasoning: { effort: "high" } },
{ reasoning: { effort: "high" } },
{ providerOptions: { reasoning: { effort: "high" } } },
],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ reasoningEffort: "high" },
{ providerOptions: { reasoningEffort: "high" } },
],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, mappedSettings]) =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk(catalogPackage), {
modelID: "api-model",
@@ -905,17 +998,19 @@ describe("ModelResolver", () => {
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("api-model")
expect(settings).toMatchObject({
apiKey: "secret",
model: (input) => {
expect(input.id).toBe("api-model")
expect(input.settings).toMatchObject({
baseURL: "https://provider.example/v1",
...mappedSettings,
})
expect(input.credential).toEqual({ type: "key", value: "secret" })
expect(input.defaults).toEqual({
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions,
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route })
},
})
},
@@ -939,11 +1034,7 @@ describe("ModelResolver", () => {
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
[
"@ai-sdk/google-vertex/anthropic",
"@opencode-ai/ai/providers/google-vertex/messages",
"claude-sonnet-4-6",
],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
@@ -961,7 +1052,8 @@ describe("ModelResolver", () => {
loadPackage: (specifier) => {
expect(specifier).toBe(nativePackage)
return Effect.succeed({
model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }),
model: (input) =>
LanguageModel.make({ id: input.id, provider: "native-provider", route: OpenAIChat.route }),
})
},
loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`),
@@ -997,10 +1089,9 @@ describe("ModelResolver", () => {
loadPackage: (specifier) => {
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("claude-sonnet-4-6")
expect(settings).toMatchObject({
accessToken: "vertex-token",
model: (input) => {
expect(input.id).toBe("claude-sonnet-4-6")
expect(input.settings).toMatchObject({
location: "eu",
project: "vertex-project",
providerOptions: {
@@ -1008,7 +1099,8 @@ describe("ModelResolver", () => {
effort: "high",
},
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
expect(input.credential).toEqual({ type: "oauth", accessToken: "vertex-token" })
return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route })
},
})
},
@@ -1035,16 +1127,16 @@ describe("ModelResolver", () => {
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.headers).toEqual({
model: (input) => {
expect(input.defaults.headers).toEqual({
"HTTP-Referer": "https://opencode.ai",
"X-OpenRouter-Title": "Custom",
})
expect(settings.body).toEqual({
expect(input.defaults.body).toEqual({
transforms: ["middle-out"],
provider: { sort: "price", only: ["anthropic"] },
})
return LanguageModel.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
return LanguageModel.make({ id: input.id, provider: "openrouter", route: OpenAIChat.route })
},
}),
},
+2 -14
View File
@@ -323,11 +323,7 @@ describe("SessionProjector", () => {
const admitted = yield* SessionInbox.admit(db, bus, {
id,
sessionID,
item: {
type: "user",
payload: { text: "expanded command template", command: { name: "command", arguments: "input" } },
delivery: "steer",
},
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
@@ -341,15 +337,7 @@ describe("SessionProjector", () => {
).toBeUndefined()
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({
session_id: sessionID,
type: "user",
seq: event.durable?.seq,
data: {
text: "expanded command template",
command: { name: "command", arguments: "input" },
},
})
).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
}),
)
+1 -3
View File
@@ -235,18 +235,16 @@ describe("Session.prompt", () => {
const message = yield* session.prompt({
sessionID,
text: "Fix the failing tests",
command: { name: "fix", arguments: "tests" },
resume: false,
})
expect(message.payload.text).toBe("Fix the failing tests")
expect(message.payload.command).toEqual({ name: "fix", arguments: "tests" })
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
type: "user",
payload: { text: "Fix the failing tests", command: { name: "fix", arguments: "tests" } },
payload: { text: "Fix the failing tests" },
delivery: "steer",
})
}),
+2
View File
@@ -17,6 +17,7 @@ import { Connection } from "@opencode-ai/schema/connection"
import { Credential } from "@opencode-ai/schema/credential"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Integration } from "@opencode-ai/schema/integration"
import { AI } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
import { Permission } from "@opencode-ai/schema/permission"
import { Pty } from "@opencode-ai/schema/pty"
@@ -95,6 +96,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreIntegration.Method, Integration.Method],
[coreIntegration.Ref, Integration.Ref],
[coreLocation.Ref, Location.Ref],
[coreAI.ProviderMetadata, AI.ProviderMetadata],
[coreAI.FinishReason, LLM.FinishReason],
[coreModel.ID, Model.ID],
[coreModel.VariantID, Model.VariantID],
+8
View File
@@ -0,0 +1,8 @@
export * as AI from "./ai.js"
import { Schema } from "effect"
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "AI.ProviderMetadata",
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
+1
View File
@@ -8,6 +8,7 @@ export { FileSystem } from "./filesystem.js"
export { Form } from "./form.js"
export { Integration } from "./integration.js"
export { LLM } from "./llm.js"
export { AI } from "./ai.js"
export { Location } from "./location.js"
export { Mcp } from "./mcp.js"
export { Model } from "./model.js"
+1 -9
View File
@@ -61,16 +61,9 @@ export const SkillAttachment = Schema.Struct({
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })
export interface CommandInvocation extends Schema.Schema.Type<typeof CommandInvocation> {}
export const CommandInvocation = Schema.Struct({
name: Schema.String,
arguments: Schema.String,
}).annotate({ identifier: "Prompt.CommandInvocation" })
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Prompt = Schema.Struct({
text: Schema.String,
command: CommandInvocation.pipe(optional),
files: Schema.Array(FileAttachment).pipe(optional),
agents: Schema.Array(AgentAttachment).pipe(optional),
skills: Schema.Array(SkillAttachment).pipe(optional),
@@ -79,10 +72,9 @@ export const Prompt = Schema.Struct({
.pipe(
statics((schema) => ({
equivalence: Schema.toEquivalence(schema),
fromUserMessage: (input: Pick<Prompt, "text" | "command" | "files" | "agents" | "skills">) =>
fromUserMessage: (input: Pick<Prompt, "text" | "files" | "agents" | "skills">) =>
schema.make({
text: input.text,
...(input.command === undefined ? {} : { command: input.command }),
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
...(input.skills === undefined ? {} : { skills: input.skills }),
+4 -1
View File
@@ -72,7 +72,10 @@ export const LocationSwitched = Schema.Struct({
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Base,
...Prompt.fields,
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
skills: Prompt.fields.skills,
type: Schema.tag("user"),
}).annotate({ identifier: "Session.Message.User" })
+1 -1
View File
@@ -39,7 +39,7 @@ describe("SessionError", () => {
})
})
test("FinishReason is the closed normalized provider set", () => {
test("FinishReason is the closed browser-safe provider set", () => {
const reasons = ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const
expect(reasons.map((reason) => Schema.decodeUnknownSync(LLM.FinishReason)(reason))).toEqual([...reasons])
expect(() => Schema.decodeUnknownSync(LLM.FinishReason)("other")).toThrow()
@@ -46,7 +46,6 @@ const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 80
const CONTEXT_MENU_WIDTH = 16
const MIDDLE_MOUSE_BUTTON = 1
const RIGHT_MOUSE_BUTTON = 2
type TabContextMenuState = {
@@ -570,14 +569,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
tabs.close(tab.sessionID)
event.preventDefault()
event.stopPropagation()
return
}
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
@@ -1117,14 +1108,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
event.preventDefault()
event.stopPropagation()
return
}
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
-13
View File
@@ -1,13 +0,0 @@
import type { StreamCommit } from "./types"
import { commandText } from "../util/command"
export function commandCommit(messageID: string | undefined, command: { name: string; arguments: string }): StreamCommit {
return {
kind: "system",
source: "system",
messageID,
partID: "command",
text: `→ Command "${commandText(command)}"`,
phase: "start",
}
}
+7 -11
View File
@@ -12,7 +12,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "../util/locale"
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
import { commandCommit } from "./command.shared"
type Trace = {
write(type: string, data?: unknown): void
@@ -174,16 +173,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
}
if (sent.mode !== "shell") {
const commit =
sent.command && sent.command.source !== "skill"
? commandCommit(sent.messageID, sent.command)
: ({
kind: "user",
text: sent.text,
phase: "start",
source: "system",
messageID: sent.messageID,
} as const)
const commit = {
kind: "user",
text: sent.text,
phase: "start",
source: "system",
messageID: sent.messageID,
} as const
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
+10 -13
View File
@@ -21,7 +21,6 @@ import {
resolveSessionInfo,
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { commandCommit } from "./command.shared"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type {
LocalReplayRow,
@@ -904,17 +903,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.shown = true
state.history.push({ ...prompt, delivery: undefined })
if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal(
prompt.command && prompt.command.source !== "skill"
? commandCommit(prompt.messageID, prompt.command)
: {
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: prompt.messageID,
},
)
rememberLocal({
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: prompt.messageID,
})
}
},
admit: async (prompt, delivery, signal) => {
@@ -1049,7 +1044,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
admitted,
)
if (prompt.messageID) {
state.localRows = state.localRows.filter((row) => row.commit.messageID !== prompt.messageID)
state.localRows = state.localRows.filter(
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
}
// Shell and skill turns never send CLI file attachments; keep them
// pending for the next prompt-shaped turn.
+1 -2
View File
@@ -1,7 +1,6 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { promptCopy, promptSame } from "./prompt.shared"
import type { RunInput, RunPrompt } from "./types"
import { commandText } from "../util/command"
const LIMIT = 200
@@ -23,7 +22,7 @@ export type RunSession = {
function messagePrompt(message: SessionMessageUser): RunPrompt {
return {
text: message.command ? commandText(message.command) : message.text,
text: message.text,
parts: [
...(message.files ?? []).map((file) => ({
type: "file" as const,
+20 -48
View File
@@ -17,8 +17,6 @@ import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import { normalizeTool, toolOutputText } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
import { commandCommit } from "./command.shared"
import { commandText } from "../util/command"
import type {
FooterApi,
FooterView,
@@ -188,12 +186,7 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
if (item.type !== "user") return undefined
return {
messageID: item.id,
prompt: {
messageID: item.id,
text: item.payload.command ? commandText(item.payload.command) : item.payload.text,
parts: [],
command: item.payload.command,
},
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
delivery: item.delivery,
}
}
@@ -662,22 +655,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.messageIDs.add(message.id)
if (!render) return
if (reuseVisibleWait && waiting) return
if (message.command) {
write([
commandCommit(message.id, message.command),
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
])
return
}
write([
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
{
kind: "user",
source: "system",
text: message.text,
phase: "start",
messageID: message.id,
},
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
])
return
}
@@ -967,19 +947,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const visible = state.messageIDs.has(event.data.inboxID)
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
if (!waiting && pending && !visible) {
write(
pending.prompt.command
? [commandCommit(event.data.inboxID, pending.prompt.command)]
: [
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
],
)
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
])
}
write([], { phase: "running", status: "waiting for assistant" })
return
@@ -992,19 +968,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.data.delivery === "queue") return
if (state.messageIDs.has(event.data.inboxID)) return
state.messageIDs.add(event.data.inboxID)
write(
pending.prompt.command
? [commandCommit(event.data.inboxID, pending.prompt.command)]
: [
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
],
)
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
])
return
}
if (event.type === "session.inbox.cancelled") {
+3 -33
View File
@@ -100,7 +100,6 @@ import {
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { commandText } from "../../util/command"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useSessionTabs } from "../../context/session-tabs"
@@ -206,11 +205,7 @@ export function Session(props: { verticalTabsWidth: number }) {
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) =>
item.delivery === "queue"
? [{ id: item.id, text: item.payload.command ? commandText(item.payload.command) : item.payload.text }]
: [],
),
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
)
const [composer, setComposer] = createStore({
open: false,
@@ -2183,29 +2178,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexShrink={0}
>
<Show when={!props.message.command}>
<text fg={theme.text.default}>{props.message.text}</text>
</Show>
<Show when={props.message.command}>
{(command) => (
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<text fg={theme.text.default}>
<span
style={{
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
fg: theme.background.default,
bold: true,
}}
>
{" command "}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{` ${commandText(command())} `}
</span>
</text>
</box>
)}
</Show>
<text fg={theme.text.default}>{props.message.text}</text>
<Show when={skills().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={skills()}>
@@ -3639,10 +3612,7 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
const body = messages.flatMap((message) => {
if (message.type === "user")
return [
`## User\n\n${message.command ? commandText(message.command) : message.text}`,
]
if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell")
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
if (message.type !== "assistant") return []
-3
View File
@@ -1,3 +0,0 @@
export function commandText(command: { name: string; arguments: string }) {
return `/${command.name}${command.arguments ? ` ${command.arguments}` : ""}`
}
@@ -1,6 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
@@ -61,41 +60,3 @@ test("releasing a transcript selection over tab controls does not activate them"
app.renderer.destroy()
}
})
test("middle-click closes a session tab without selecting it", async () => {
const [active, setActive] = createSignal("first")
const closed: Array<string | undefined> = []
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
select: setActive,
close: (sessionID?: string) => closed.push(sessionID),
move() {},
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 8 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.click(40, 0, MouseButton.MIDDLE)
expect(closed).toEqual(["second"])
expect(active()).toBe("first")
} finally {
app.renderer.destroy()
}
})
@@ -103,16 +103,6 @@ describe("run session shared", () => {
})
})
test("uses presentation text for command history", () => {
const out = createSession([
userMessage("msg-user-1", "expanded command template", {
command: { name: "command", arguments: "input" },
}),
])
expect(out.turns[0]?.prompt.text).toBe("/command input")
})
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
const parts = [
{
@@ -667,10 +667,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
timeCreated: 1,
type: "user",
payload: {
text: "expanded command template",
command: { name: "command", arguments: "input" },
},
payload: { text: "follow up" },
delivery: "queue",
},
{
@@ -710,7 +707,7 @@ describe("V2 mini transport", () => {
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "system", messageID: "msg_queued", text: '→ Command "/command input"' }),
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(pending()).toEqual([["msg_cancelled", "queue"]])
events.push({