mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 03:16:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e09a0493bb |
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user