Compare commits

...
43 changed files with 363 additions and 225 deletions
-1
View File
@@ -205,7 +205,6 @@
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
"x86_64-linux": "sha256-W2QBi7/jtbvLp3Gj+nR49+wqVgCB0qfnQ30C0PQOjO4=",
"aarch64-linux": "sha256-R4YKo8JUvKd77QYehTNpj/65oodeLBQ8mdxLzBLrFdw=",
"aarch64-darwin": "sha256-7v2ABLG6zsIMSzsYu06BWEqbLL4HOeKyZFBx/gx87SI=",
"x86_64-darwin": "sha256-emKRoQILMzB5ko45dtMPuZGV2pAyszcwzKUh9ey9JZM="
}
}
+18 -14
View File
@@ -456,6 +456,10 @@ export type V2EventServerConnected = {
data: {}
}
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
@@ -1656,19 +1660,19 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ModelVariant = {
id: string
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
@@ -1898,7 +1902,7 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
settings?: ModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
@@ -2107,13 +2111,13 @@ export type ConfigEntry =
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigProviderSettings
settings?: ConfigModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigProviderSettings
settings?: ConfigModelSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
@@ -2224,7 +2228,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
state?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -3176,7 +3180,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3493,7 +3497,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3810,7 +3814,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
+3 -3
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.state = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.state = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.state = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+3 -7
View File
@@ -15,8 +15,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Source and execution model
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
TypeScript-only syntax is rejected rather than stripped before execution.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
@@ -41,8 +40,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
default in a `for...of` head and Unicode-escaped keywords.
## Values and literals
@@ -494,9 +491,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
short orientation to the supported subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
+1 -8
View File
@@ -19,12 +19,6 @@
"exports": {
".": "./src/index.ts"
},
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit",
@@ -32,8 +26,7 @@
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:"
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+9 -7
View File
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
imports?: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
@@ -41,12 +41,14 @@ try {
]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
if (pkg.imports) {
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
]),
)
}
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
+1 -14
View File
@@ -1,14 +1,10 @@
import { parse, type Program } from "acorn"
import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createBuiltins } from "./intrinsics.js"
import { PendingThrow } from "./model.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -110,16 +106,7 @@ export const executeProgram = <R>(
}
const parseProgram = (code: string): Program => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
const bodyEnd = transpiled.outputText.lastIndexOf("}")
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
return parse(executableCode, {
return parse(code, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
@@ -1,25 +0,0 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -1,11 +0,0 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
+13 -7
View File
@@ -15,6 +15,12 @@ const error = async (code: string) => {
return result.error
}
describe("source syntax", () => {
test("rejects TypeScript-only syntax", async () => {
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
})
})
describe("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
@@ -101,7 +107,7 @@ describe("host errors escaping built-ins", () => {
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -109,13 +115,13 @@ describe("host errors escaping built-ins", () => {
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 14)",
"(line 1, col 10)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 2, col 5)",
"(line 1, col 8)",
)
})
@@ -128,7 +134,7 @@ describe("host errors escaping built-ins", () => {
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
})
})
@@ -147,7 +153,7 @@ describe("call depth", () => {
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
})
test("the limit is 10000 nested calls", async () => {
+8 -8
View File
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
import { ProviderShared } from "@opencode/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, Info } from "./model.js"
import type { ID, RuntimeInfo } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export interface SDKEvent {
readonly model: Info
readonly model: RuntimeInfo
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: Info
readonly model: RuntimeInfo
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: Info, pkg: string) {
function prepareOptions(model: RuntimeInfo, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.canonical ?? model.providerID,
@@ -182,8 +182,8 @@ export interface Interface {
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
@@ -302,7 +302,7 @@ export const locationLayer = Layer.effect(
}),
)
function modelFromLanguage(info: Info, language: LanguageModelV3) {
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const providerID = info.canonical ?? info.providerID
@@ -399,7 +399,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: Info, packageName: string) {
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
+21 -16
View File
@@ -8,6 +8,7 @@ import { AISDK } from "./aisdk.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
import type { RuntimeInfo } from "./model.js"
import { Npm } from "@opencode/util/npm"
import { Provider } from "./provider.js"
@@ -117,9 +118,9 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
/** Model policy overrides the provider policy; omitted means summary compaction. */
readonly compaction?: Provider.Compaction
/** Model transport overrides the provider transport; omitted means HTTP. */
/** Provider transport policy; omitted means HTTP. */
readonly transport?: Provider.Transport
}
@@ -149,7 +150,7 @@ export const withVariant = (
variant
? {
...model,
settings: Provider.mergeOverlay(model.settings, variant.settings),
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
headers: Provider.mergeHeaders(model.headers, variant.headers),
body: Provider.mergeOverlay(model.body, variant.body),
}
@@ -159,11 +160,11 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
}
export const fromCatalogModel = (
model: Info,
model: RuntimeInfo,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<
@@ -191,7 +192,7 @@ export const fromCatalogModel = (
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
model: RuntimeInfo,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
@@ -248,7 +249,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
})
})
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return {
...model,
@@ -260,7 +261,7 @@ function prepareRuntimeModel(model: Info, credential: Credential.Value | undefin
}
function validateProviderVariables(
model: Info,
model: RuntimeInfo,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
@@ -270,7 +271,7 @@ function validateProviderVariables(
}
function prepareProviderSettings(
model: Info,
model: RuntimeInfo,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
@@ -280,14 +281,14 @@ function prepareProviderSettings(
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
@@ -310,14 +311,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
return { apiKey: credential.access }
}
const unsupported = (model: Info) =>
const unsupported = (model: RuntimeInfo) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
@@ -359,7 +360,11 @@ export const layer = Layer.effect(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const runtimeInfo = yield* withVariant(selected, variant)
const selectedVariant = yield* withVariant(selected, variant)
const runtimeInfo: RuntimeInfo = {
...selectedVariant,
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
}
const model = yield* fromCatalogModel(runtimeInfo, credential, {
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
@@ -382,7 +387,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
transport: provider?.settings?.transport,
}
})
return Service.of({
@@ -406,7 +411,7 @@ export const layer = Layer.effect(
}),
)
function hasConfiguredAuth(model: Info) {
function hasConfiguredAuth(model: RuntimeInfo) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
+7 -1
View File
@@ -36,6 +36,9 @@ export type Ref = typeof Ref.Type
export const Info = Model.Info
export type Info = Model.Info
/** Effective provider and model settings used only while constructing a runtime model. */
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
export type MutableInfo = DeepMutable<Info>
export { Event } from "@opencode/schema/model"
@@ -188,7 +191,10 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
+15 -18
View File
@@ -7,7 +7,6 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
@@ -140,16 +139,18 @@ export const AzurePlugin = define({
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
if (resourceName)
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
})
yield* ctx.model.transform((models) => {
@@ -167,10 +168,6 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
})
}
}
@@ -242,9 +239,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
if (settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
+6 -6
View File
@@ -254,10 +254,13 @@ export const OpenAIPlugin = define({
yield* ctx.provider.transform((providers) => {
const item = providers.get(Provider.ID.openai)
if (!item) return
if (!chatgpt) return
const account = chatgpt.metadata?.accountID
const account = chatgpt?.metadata?.accountID
providers.update(item.provider.id, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
...(chatgpt ? { baseURL: codexBaseURL } : {}),
})
if (!chatgpt) return
provider.headers = Provider.mergeHeaders(provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
@@ -270,9 +273,6 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+6 -7
View File
@@ -95,14 +95,13 @@ export const XAIPlugin = define({
editor.method.update(device(ctx.app))
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
yield* ctx.provider.transform((providers) => {
if (!providers.get(providerID)) return
providers.update(providerID, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
transport: provider.settings?.transport ?? "websocket",
})
}
})
})
}),
})
+5
View File
@@ -134,11 +134,16 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, CORE_KEYS)
}
export function modelSettings(settings: Settings | undefined) {
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
}
export function mergeOverlay(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
+1 -1
View File
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
+1 -1
View File
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const base = { id, sessionID, time: { created: message.time.created }, delivery }
if (message.type === "user")
return User.make({
+3 -3
View File
@@ -222,7 +222,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.state = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
@@ -263,7 +263,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.state = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
projectTerminalSnapshot(draft, event)
@@ -274,7 +274,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.state = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
+1 -1
View File
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const updateMessage = (message: SessionMessage.Info) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
+3 -1
View File
@@ -107,7 +107,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.orDie,
)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
+5 -4
View File
@@ -27,14 +27,15 @@ import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
Model.Info.make({
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
...Model.Info.make({
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
modelID: Model.ID.make("api-model"),
package: Provider.aisdk(packageName),
settings,
limit: { context: 100, output: 20 },
})
}),
settings,
})
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
+2 -1
View File
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
}),
)
it.effect("resolves provider and model overlay merges", () =>
it.effect("keeps settings scoped while resolving request overlay merges", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -533,6 +533,7 @@ describe("Provider and Model", () => {
})
const model = required(yield* models.get(providerID, modelID))
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
+19 -11
View File
@@ -37,6 +37,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
const providers = yield* Provider.Service
yield* addPlugin([
new Document({
type: "document",
@@ -63,6 +64,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -73,8 +75,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("inherits the provider websocket policy with model overrides", () =>
it.effect("keeps the provider websocket policy out of model settings", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
yield* addPlugin([
new Document({
@@ -83,8 +86,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
models: {
inherited: {
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
},
},
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -92,10 +99,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
])
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
expect(inherited.settings).toEqual({ shared: "provider", model: true })
expect(untouched.settings?.transport).toBeUndefined()
}),
)
@@ -106,7 +112,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
@@ -122,7 +128,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
yield* addPlugin([
new Document({
@@ -131,15 +138,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
models: { override: { modelID: builtin.model } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
}),
)
}
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
),
)
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
@@ -472,9 +472,8 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.settings?.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
}
@@ -205,7 +205,8 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.settings?.transport).toBe("websocket")
expect(provider.settings?.transport).toBe("websocket")
expect(model.settings?.transport).toBeUndefined()
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -83,8 +83,10 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const provider = yield* providers.get(providerID)
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.settings?.transport).toBe("websocket")
expect(provider?.settings?.transport).toBe("websocket")
expect(model?.settings?.transport).toBeUndefined()
}),
)
})
+15
View File
@@ -42,4 +42,19 @@ describe("Provider", () => {
reasoningEffort: "high",
})
})
test("inherits shared and loose settings without provider-only policies", () => {
expect(
Provider.modelSettings({
timeout: 60_000,
chunkTimeout: 30_000,
transport: "websocket",
compaction: { type: "native" },
reasoningEffort: "high",
}),
).toEqual({
compaction: { type: "native" },
reasoningEffort: "high",
})
})
})
+2 -2
View File
@@ -690,7 +690,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "stop",
rawFinish: "stop_sequence",
providerState: { response: "ended" },
state: { response: "ended" },
cost: Money.USD.make(1),
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
@@ -700,7 +700,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "blocked",
providerState: { response: "failed" },
state: { response: "failed" },
error: { type: "provider.invalid-request", message: "Failed" },
snapshot: { end: "snap_failed", files: ["src/failed.ts"] },
time: { completed: created },
+2 -2
View File
@@ -4953,7 +4953,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
providerState: { responseId: "response-1", serviceTier: "priority" },
state: { responseId: "response-1", serviceTier: "priority" },
content: [Expected.text("Complete")],
},
])
@@ -4984,7 +4984,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
state: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
+39 -4
View File
@@ -13251,6 +13251,27 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13270,7 +13291,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -13293,7 +13314,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -15508,7 +15529,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
@@ -15614,6 +15635,20 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15621,7 +15656,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
+16 -2
View File
@@ -17,6 +17,14 @@ export const Settings = Schema.StructWithRest(
).annotate({ identifier: "Config.Provider.Settings" })
export type Settings = typeof Settings.Type
export const ModelSettings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
}),
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
).annotate({ identifier: "Config.Model.Settings" })
export type ModelSettings = typeof ModelSettings.Type
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
@@ -25,6 +33,12 @@ export const Overlays = {
body: JsonRecord.pipe(optional),
}
const ModelOverlays = {
settings: ModelSettings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
headers: Overlays.headers,
body: Overlays.body,
@@ -57,11 +71,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...Overlays,
...ModelOverlays,
capabilities: Capabilities.pipe(optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
...ModelOverlays,
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
+17 -2
View File
@@ -56,6 +56,21 @@ export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tok
})
export type MaxTokensField = typeof MaxTokensField.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
}),
// Provider packages may define arbitrary model-level options beyond OpenCode's shared compaction policy.
[Schema.Record(Schema.String, Schema.Any)],
).annotate({ identifier: "Model.Settings" })
export type Settings = typeof Settings.Type
export const Overlays = {
settings: Settings.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
@@ -97,7 +112,7 @@ export const Cost = Schema.Struct({
export interface Variant extends Schema.Schema.Type<typeof Variant> {}
export const Variant = Schema.Struct({
id: VariantID,
...Provider.Overlays,
...Overlays,
}).annotate({ identifier: "Model.Variant" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
@@ -110,7 +125,7 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
...Provider.Overlays,
...Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
time: Schema.Struct({
+10 -2
View File
@@ -1,6 +1,6 @@
export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { Predicate, Schema } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
@@ -222,7 +222,7 @@ export const Assistant = Schema.Struct({
}).pipe(optional),
finish: FinishReason.pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: ProviderState.pipe(optional),
state: ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
error: SessionError.Error.pipe(optional),
@@ -318,3 +318,11 @@ export type Info =
| Compaction
| Idle
export type Type = Info["type"]
/** Reads assistant rows stored before `providerState` was renamed to `state`. */
export function persisted(input: unknown) {
if (!Predicate.isObject(input) || input.type !== "assistant" || "state" in input || !("providerState" in input))
return input
const { providerState, ...rest } = input
return { ...rest, state: providerState }
}
@@ -169,6 +169,7 @@ describe("contract hygiene", () => {
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
ConfigProvider.ModelSettings,
ConfigProvider.Settings,
FileSystem.Submatch,
Form.Field,
@@ -183,6 +184,7 @@ describe("contract hygiene", () => {
Model.Ref,
Model.Capabilities,
Model.Cost,
Model.Settings,
Model.Variant,
Project.Current,
Worktree.Directory,
@@ -239,11 +241,12 @@ describe("contract hygiene", () => {
expect(
sources
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
.filter((item) => item.file !== "provider.ts" && item.file !== "model.ts" && item.file !== "integration.ts")
.map((item) => item.source)
.join("\n"),
).not.toContain("Schema.Any")
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
expect(sources.find((item) => item.file === "model.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
expect(source).not.toContain("Schema.mutable")
})
+4 -7
View File
@@ -75,13 +75,10 @@ describe("Model.Info", () => {
})
})
describe("Model.Capabilities", () => {
test("decodes the optional transport setting", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
transport: "websocket",
describe("Model.Settings", () => {
test("preserves provider-specific model options", () => {
expect(Schema.decodeUnknownSync(Model.Settings)({ providerOption: true })).toEqual({
providerOption: true,
})
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
})
})
+10 -1
View File
@@ -23,14 +23,23 @@ test("assistant terminal diagnostics remain optional and round trip", () => {
...assistant,
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
state: { promptFeedback: { blockReason: "SAFETY" } },
}),
),
).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
state: { promptFeedback: { blockReason: "SAFETY" } },
})
const legacy = SessionMessage.persisted({
...assistant,
providerState: { promptFeedback: { blockReason: "SAFETY" } },
})
expect(decode(legacy).state).toEqual({ promptFeedback: { blockReason: "SAFETY" } })
expect(encode(decode(legacy))).toEqual({
...assistant,
state: { promptFeedback: { blockReason: "SAFETY" } },
})
})
test("failed steps only override the assistant finish for content filters", () => {
+39 -4
View File
@@ -13251,6 +13251,27 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13270,7 +13291,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -13293,7 +13314,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -15508,7 +15529,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
@@ -15614,6 +15635,20 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15621,7 +15656,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
+39 -4
View File
@@ -13251,6 +13251,27 @@
"required": ["input", "output"],
"additionalProperties": false
},
"Config.Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {
"anyOf": [
{},
{
"type": "null"
}
]
}
}
]
},
"Config.ModelEncoded": {
"type": "object",
"properties": {
@@ -13270,7 +13291,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -13293,7 +13314,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
"$ref": "#/components/schemas/Config.Model.Settings"
},
"headers": {
"type": "object",
@@ -15508,7 +15529,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
@@ -15614,6 +15635,20 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"Model.Settings": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
}
},
"allOf": [
{
"type": "object",
"additionalProperties": {}
}
]
},
"Model.Variant": {
"type": "object",
"properties": {
@@ -15621,7 +15656,7 @@
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
"$ref": "#/components/schemas/Model.Settings"
},
"headers": {
"type": "object",
+1 -2
View File
@@ -506,7 +506,6 @@ headers, and model variants.
}
```
`settings.transport: "websocket"` on a provider or model selects its session
WebSocket; a model value overrides the provider value.
Provider `settings.transport: "websocket"` selects its session WebSocket.
See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration.
+2 -6
View File
@@ -558,9 +558,6 @@ sessions.
"providers": {
"openai": {
"settings": { "transport": "http" },
"models": {
"gpt-5.5": { "settings": { "transport": "websocket" } },
},
},
},
}
@@ -568,9 +565,8 @@ sessions.
WebSocket behavior follows these rules:
- Built-in providers opt supported models in according to their own policy.
- `settings.transport: "websocket"` enables a provider or model; `"http"` disables it.
- A model value overrides its provider value.
- Built-in providers opt supported routes in according to their own policy.
- Provider `settings.transport: "websocket"` enables it; `"http"` disables it.
- `"websocket"` on a route without a WebSocket channel logs a warning and falls back to HTTP.
- OpenAI provider compaction uses the same connection.
- xAI continues from stored responses only. With its default `store: false`, each step is sent in full over the reused