Compare commits

...
Author SHA1 Message Date
Aiden Cline e09a0493bb refactor(core): replace websocket flags with a single transport preference
Drop Model.capabilities.responsesWebsockets, which provider plugins
precomputed from route facts, and replace the websocket booleans on
Model.Info, Provider.Info and their config entries with
transport?: "http" | "websocket".

The route now decides whether it has a WebSocket channel. Omitted uses
the channel when offered and HTTP otherwise; "http" never offers the
session executor; an explicit "websocket" on a route without a channel
logs a warning via the new WebSocketChannelExecutor.unavailable hook and
falls back to HTTP.
2026-09-10 20:52:36 -05:00
25 changed files with 132 additions and 156 deletions
@@ -178,7 +178,8 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
}),
execute: (prepared, request, runtime, executeOptions) =>
Effect.gen(function* () {
if (!executeOptions?.webSocket || !prepared.channel) return yield* http.execute(prepared.http, request, runtime)
if (!executeOptions?.webSocket || !prepared.channel)
return yield* http.execute(prepared.http, request, runtime, executeOptions)
let fallbackHttp: HttpContext | undefined
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
+2 -1
View File
@@ -87,8 +87,9 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware,
}
}),
execute: (prepared, _request, runtime) =>
execute: (prepared, _request, runtime, options) =>
Effect.gen(function* () {
if (options?.webSocket?.unavailable) yield* options.webSocket.unavailable
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
return {
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
@@ -6,6 +6,8 @@ export interface WebSocketChannelExecutor {
readonly execute: (
exchange: WebSocketChannelExchange,
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
/** Runs when the route has no WebSocket channel for this request and carries it over HTTP instead. */
readonly unavailable?: Effect.Effect<void>
}
export interface WebSocketChannelExecution {
+10 -1
View File
@@ -36,13 +36,21 @@ it.effect("Meta composes baseline protocols with provider-owned endpoints and de
it.effect("Meta Responses stays on HTTP when a WebSocket executor is supplied", () =>
Effect.gen(function* () {
let unavailable = 0
for (const baseURL of ["https://api.meta.ai/v1", "https://gateway.example/v1"]) {
const response = yield* LLMClient.generate(
LLM.request({
model: Meta.configure({ apiKey: "fixture", baseURL }).responses("muse-spark-1.3"),
prompt: "Hello",
}),
{ webSocket: { execute: () => Effect.die("Meta must not execute WebSocket requests") } },
{
webSocket: {
execute: () => Effect.die("Meta must not execute WebSocket requests"),
unavailable: Effect.sync(() => {
unavailable += 1
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -76,6 +84,7 @@ it.effect("Meta Responses stays on HTTP when a WebSocket executor is supplied",
expect(response.text).toBe("Hello")
expect(response.finishReason.normalized).toBe("stop")
}
expect(unavailable).toBe(2)
}),
)
@@ -1138,9 +1138,13 @@ describe("OpenAI Responses route", () => {
},
]
const unavailable = yield* Ref.make(0)
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
webSocket: {
execute: () => Effect.die("unexpected WebSocket request"),
unavailable: Ref.update(unavailable, (value) => value + 1),
},
}).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -1154,6 +1158,7 @@ describe("OpenAI Responses route", () => {
),
),
)
expect(yield* Ref.get(unavailable)).toBe(cases.length)
}),
)
+7 -10
View File
@@ -194,12 +194,9 @@ export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ModelCapabilities = {
tools: boolean
input: Array<string>
output: Array<string>
responsesWebsockets?: boolean
}
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
@@ -1369,7 +1366,7 @@ export type ProviderInfo = {
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
websocket?: boolean
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -1850,7 +1847,7 @@ export type ModelInfo = {
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
websocket?: boolean
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -2027,7 +2024,7 @@ export type ConfigEntry =
providers?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
@@ -2038,7 +2035,7 @@ export type ConfigEntry =
models?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
+8 -1
View File
@@ -342,7 +342,14 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
// AI SDK packages own their HTTP client, so a session WebSocket executor is never used here.
streamPrepared: (prepared, _request, _runtime, options) =>
Stream.unwrap(
Effect.as(
options?.webSocket?.unavailable ?? Effect.void,
streamLanguage(language, prepared as LanguageModelV3CallOptions),
),
),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
+1 -1
View File
@@ -77,7 +77,7 @@ const layer = Layer.effect(
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
compaction: model.compaction ?? provider.compaction,
websocket: model.websocket ?? provider.websocket,
transport: model.transport ?? provider.transport,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
+2 -2
View File
@@ -58,7 +58,7 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.websocket !== undefined) provider.websocket = item.websocket
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -79,7 +79,7 @@ export const Plugin = define({
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.websocket !== undefined) model.websocket = config.websocket
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+3 -3
View File
@@ -85,8 +85,8 @@ export interface Resolved {
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
/** Whether the session WebSocket may carry this model's requests when the route supports it. */
readonly websocket: boolean
/** Model preference overrides the provider preference; omitted uses the route's WebSocket channel when it has one. */
readonly transport?: Info["transport"]
}
export interface Interface {
@@ -323,7 +323,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
websocket: selected.websocket ?? true,
transport: selected.transport,
}
})
return Service.of({
@@ -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"
@@ -162,7 +161,6 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft)) draft.capabilities.responsesWebsockets = true
})
}
}
@@ -241,12 +239,3 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_RESOURCE_NAME}", resourceName)
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if (Provider.packageName(model.package ?? provider.package) !== "@ai-sdk/azure") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
if (settings?.useCompletionUrls === true || settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
return /^https:\/\/[^/]+\.openai\.azure\.com(?:\/|$)/i.test(settings.baseURL)
}
+1 -7
View File
@@ -253,13 +253,7 @@ export const OpenAIPlugin = define({
yield* load()
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
})
}
if (!chatgpt) return
if (!item || !chatgpt) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
-9
View File
@@ -95,15 +95,6 @@ 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.catalog.transform((catalog) => {
const provider = catalog.provider.get(providerID)
if (!provider) return
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
})
}
})
}),
})
+18 -6
View File
@@ -369,14 +369,26 @@ export const layer = Layer.effect(
kind: input.kind,
})
: undefined
// HTTP hooks must observe every request, so they keep the provider on HTTP.
// HTTP hooks must observe every request, so they keep the provider on HTTP. The route decides
// whether it has a WebSocket channel; an explicit "websocket" preference on a route without one
// warns before the route falls back to HTTP.
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" &&
!hasHttpHooks &&
resolved.capabilities.responsesWebsockets === true &&
resolved.websocket
? { webSocket: transport.bind(session.id) }
...(input.webSocket === "session" && !hasHttpHooks && resolved.transport !== "http"
? {
webSocket: {
...transport.bind(session.id),
...(resolved.transport === "websocket"
? {
unavailable: Effect.logWarning("session websocket not offered by route; using http", {
sessionTransport: "websocket",
model: `${resolved.ref.providerID}/${resolved.ref.id}`,
route: request.model.route.id,
}),
}
: {}),
},
}
: {}),
}
const executeTool: Prepared["executeTool"] = (input) =>
+2 -2
View File
@@ -60,7 +60,7 @@ export const resolved = (
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Provider.Compaction
readonly websocket?: boolean
readonly transport?: Provider.Transport
},
): Resolved => ({
model,
@@ -73,7 +73,7 @@ export const resolved = (
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
websocket: options.websocket ?? true,
transport: options.transport,
})
const layer = Layer.effect(
+6 -6
View File
@@ -80,7 +80,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("inherits the provider websocket policy with model overrides", () =>
it.effect("inherits the provider transport preference with model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin([
@@ -90,8 +90,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
websocket: false,
models: { inherited: {}, override: { websocket: true } },
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -101,9 +101,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const inherited = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.websocket).toBe(false)
expect(override.websocket).toBe(true)
expect(untouched.websocket).toBeUndefined()
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
}),
)
-1
View File
@@ -94,7 +94,6 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
websocket: true,
})
}),
)
@@ -452,53 +452,6 @@ describe("AzurePlugin", () => {
),
)
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = {
responses: Model.ID.make("responses"),
chat: Model.ID.make("chat"),
preview: Model.ID.make("preview"),
deploymentURL: Model.ID.make("deployment-url"),
gateway: Model.ID.make("gateway"),
nonAzure: Model.ID.make("non-azure"),
}
yield* catalog.transform((editor) => {
editor.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
editor.model.update(Provider.ID.azure, models.responses, () => {})
editor.model.update(Provider.ID.azure, models.chat, (model) => {
model.settings = { useCompletionUrls: true }
})
editor.model.update(Provider.ID.azure, models.preview, (model) => {
model.settings = { apiVersion: "2025-04-01-preview" }
})
editor.model.update(Provider.ID.azure, models.deploymentURL, (model) => {
model.settings = { useDeploymentBasedUrls: true }
})
editor.model.update(Provider.ID.azure, models.gateway, (model) => {
model.settings = { baseURL: "https://gateway.example/azure" }
})
editor.model.update(Provider.ID.azure, models.nonAzure, (model) => {
model.package = Provider.aisdk("@ai-sdk/anthropic")
})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(Provider.ID.azure, models.responses)).capabilities.responsesWebsockets,
).toBe(true)
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure])
expect(
required(yield* catalog.model.get(Provider.ID.azure, modelID)).capabilities.responsesWebsockets,
).toBeUndefined()
}),
),
)
it.effect("rejects missing resourceName when baseURL is not configured", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
@@ -204,7 +204,6 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.capabilities.responsesWebsockets).toBe(true)
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -214,7 +213,7 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("selects Azure WebSocket from capability unless the policy disables it", () =>
it.effect("offers the session WebSocket unless the transport preference is http", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
@@ -235,13 +234,13 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (websocket?: boolean) =>
const prepare = (preference?: Model.Info["transport"]) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
websocket,
transport: preference,
})
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
@@ -268,11 +267,15 @@ describe("OpenAIPlugin", () => {
)
const prepared = yield* prepare()
const disabled = yield* prepare(false)
const explicit = yield* prepare("websocket")
const http = yield* prepare("http")
expect(prepared.options.webSocket).toBe(executor)
expect(prepared.options.webSocket?.execute).toBe(executor.execute)
expect(prepared.options.webSocket?.unavailable).toBeUndefined()
expect(prepared.options.http).toBeUndefined()
expect(disabled.options.webSocket).toBeUndefined()
expect(explicit.options.webSocket?.execute).toBe(executor.execute)
expect(explicit.options.webSocket?.unavailable).toBeDefined()
expect(http.options.webSocket).toBeUndefined()
}),
)
})
@@ -68,23 +68,4 @@ describe("XAIPlugin", () => {
})
}),
)
it.effect("marks xAI deployments as Responses WebSocket capable", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("xai")
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/xai")
})
editor.model.update(providerID, Model.ID.make("grok-4.6"), () => {})
})
yield* addPlugin()
expect((yield* catalog.model.get(providerID, Model.ID.make("grok-4.6")))?.capabilities.responsesWebsockets).toBe(
true,
)
}),
)
})
+28 -3
View File
@@ -14243,6 +14243,9 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"modelID": {
"type": "string"
},
@@ -14351,6 +14354,9 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"canonical": {
"type": "string"
},
@@ -16198,9 +16204,6 @@
"items": {
"type": "string"
}
},
"responsesWebsockets": {
"type": "boolean"
}
},
"required": ["tools", "input", "output"],
@@ -16297,6 +16300,9 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"type": "object"
},
@@ -17285,6 +17291,9 @@
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"transport": {
"$ref": "#/components/schemas/Provider.Transport"
},
"settings": {
"type": "object"
},
@@ -17323,6 +17332,10 @@
"Provider.Settings": {
"type": "object"
},
"Provider.Transport": {
"type": "string",
"enum": ["http", "websocket"]
},
"ProviderNotFoundErrorEncoded": {
"type": "object",
"properties": {
@@ -18256,6 +18269,12 @@
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18295,6 +18314,12 @@
},
"error": {
"$ref": "#/components/schemas/Session.StructuredError"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
}
},
"required": ["type", "id", "time", "status", "reason", "error"],
+6 -4
View File
@@ -42,8 +42,9 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
websocket: Schema.Boolean.pipe(optional).annotate({
description: "Use the provider's WebSocket transport for this model. Defaults to the provider policy.",
transport: Provider.Transport.pipe(optional).annotate({
description:
"Session transport for this model. Defaults to the provider transport, which uses the WebSocket channel when the route offers one.",
}),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
@@ -63,8 +64,9 @@ class Model extends Schema.Class<Model>("Config.Model")({
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
websocket: Schema.Boolean.pipe(optional).annotate({
description: "Use the provider's WebSocket transport when the route supports it. Defaults to true.",
transport: Provider.Transport.pipe(optional).annotate({
description:
"Session transport for this provider's models. Omitted uses the WebSocket channel when the route offers one and HTTP otherwise; \"websocket\" on a route without a channel warns and falls back to HTTP.",
}),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
+2 -3
View File
@@ -67,7 +67,6 @@ export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
input: Schema.Array(Schema.String),
output: Schema.Array(Schema.String),
responsesWebsockets: Schema.Boolean.pipe(optional),
})
.annotate({ identifier: "Model.Capabilities" })
.pipe(
@@ -107,8 +106,8 @@ export const Info = Schema.Struct({
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
/** Session WebSocket policy; omitted inherits the provider policy, which defaults to enabled. */
websocket: Schema.Boolean.pipe(optional),
/** Omitted inherits the provider transport preference. */
transport: Provider.Transport.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+8 -2
View File
@@ -34,6 +34,13 @@ export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
/**
* Session transport preference. Omitted selects the WebSocket channel when the route offers one and
* HTTP otherwise; an explicit "websocket" on a route without a channel warns and falls back to HTTP.
*/
export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" })
export type Transport = typeof Transport.Type
export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
@@ -59,8 +66,7 @@ export const Info = Schema.Struct({
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
/** Session WebSocket policy for routes that support it; omitted means enabled. */
websocket: Schema.Boolean.pipe(optional),
transport: Transport.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
+7 -7
View File
@@ -82,12 +82,12 @@ describe("Model.Info", () => {
})
})
describe("Model.Capabilities", () => {
test("decodes optional Responses WebSocket support", () => {
const decode = Schema.decodeUnknownSync(Model.Capabilities)
const base = { tools: true, input: ["text"], output: ["text"] }
expect(decode(base)).toEqual(base)
expect(decode({ ...base, responsesWebsockets: true })).toEqual({ ...base, responsesWebsockets: true })
describe("Model.Info transport", () => {
test("decodes the optional transport preference", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "http" }).transport).toBe("http")
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow()
})
})