From 0d7f9e73beb76953c7a240ba71262a7b6bb3cf38 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 17:28:47 +0530 Subject: [PATCH 1/8] fix(core): retry ambiguous websocket deliveries --- packages/core/src/session/runner/retry.ts | 4 +++- packages/core/test/session-error.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index a010a8e52f0a..cbadaaf525f0 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -35,8 +35,10 @@ export function isRetryable(error: AIError) { case "RateLimit": case "ProviderInternal": return true + // HTTP never learns whether the provider saw a request and always retries. WebSocket does: + // only a request the provider accepted or rejected is final; an ambiguous send is pre-output. case "Transport": - return error.reason.delivery === undefined || error.reason.delivery === "not-sent" + return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected" case "InvalidProviderOutput": return error.reason.classification === "incomplete-stream" // Unrecognized failures retry: classification records affirmative diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 6de68bfe14c3..d491c28707f4 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -168,7 +168,7 @@ describe("toSessionError", () => { expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false]) }) - test("retries transport failures only when delivery is absent or not sent", () => { + test("retries transport failures unless the provider accepted or rejected the request", () => { const retryable = [ llm(new TransportError({ message: "http transport", transport: "http", operation: "request" })), llm( @@ -180,8 +180,6 @@ describe("toSessionError", () => { phase: "connect", }), ), - ] - const ineligible = [ llm( new TransportError({ message: "send uncertain", @@ -191,6 +189,8 @@ describe("toSessionError", () => { phase: "send", }), ), + ] + const ineligible = [ llm( new TransportError({ message: "response interrupted", @@ -212,8 +212,8 @@ describe("toSessionError", () => { ), ] - expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true]) - expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false]) + expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true]) + expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false]) }) test("honors provider retry header overrides", () => { From c08d99bec25eabd51496dd752bcbf9c224789620 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 17:28:47 +0530 Subject: [PATCH 2/8] fix(core): bound websocket connects and keep failed sessions on http --- packages/core/src/session/model-transport.ts | 19 ++++++- .../core/test/session-model-transport.test.ts | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session/model-transport.ts b/packages/core/src/session/model-transport.ts index b8d5ab1c289e..df46bf114562 100644 --- a/packages/core/src/session/model-transport.ts +++ b/packages/core/src/session/model-transport.ts @@ -20,6 +20,7 @@ import { webSocketConstructor } from "../effect/app-node-platform.js" const ROTATE_AFTER_MS = 55 * 60 * 1000 const INBOUND_CAPACITY = 128 +const CONNECT_TIMEOUT = "10 seconds" const IDLE_TIMEOUT = "5 minutes" const events = Metric.counter("opencode_session_websocket_events_total", { description: "Session WebSocket lifecycle events", @@ -167,7 +168,20 @@ export const makeLayer = (connector: WebSocketConnector) => return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const connection = yield* restore( - connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")), + connector.open(exchange.connect).pipe( + Effect.timeoutOrElse({ + duration: CONNECT_TIMEOUT, + orElse: () => + transportError("Timed out opening the Session WebSocket", { + url: exchange.connect.url, + operation: "request", + code: "connect-timeout", + phase: "connect", + delivery: "not-sent", + }), + }), + Effect.withSpan("SessionModelTransport.connect"), + ), ) if (owner.closed) { yield* connection.close @@ -305,6 +319,9 @@ export const makeLayer = (connector: WebSocketConnector) => }).pipe( Effect.andThen(metric("connect_failure")), Effect.andThen(metric("fallback")), + // A network that refuses the upgrade would otherwise charge every step for a failed + // connect; the Session stays on HTTP for the rest of this process. + Effect.tap(() => Effect.sync(() => (owner.httpFallback = true))), Effect.as(undefined), ), ), diff --git a/packages/core/test/session-model-transport.test.ts b/packages/core/test/session-model-transport.test.ts index 8ee4b420d6e8..aad447263ee3 100644 --- a/packages/core/test/session-model-transport.test.ts +++ b/packages/core/test/session-model-transport.test.ts @@ -526,6 +526,57 @@ describe("SessionModelTransport", () => { ) }) + test("keeps a Session on http after a failed connect", async () => { + let attempts = 0 + const connector: WebSocketConnector = { + open: () => + Effect.sync(() => attempts++).pipe( + Effect.andThen( + Effect.fail( + new AIError({ + reason: new TransportError({ + message: "upgrade rejected", + transport: "websocket", + operation: "request", + phase: "connect", + delivery: "not-sent", + }), + }), + ), + ), + ), + } + + await run( + connector, + Effect.gen(function* () { + const transport = yield* SessionModelTransport.Service + const executor = transport.bind(session) + expect(yield* collect(executor, exchange("first"))).toEqual(["fallback:first"]) + expect(yield* collect(executor, exchange("second"))).toEqual(["fallback:second"]) + // One failed upgrade per Session, not one per step. + expect(attempts).toBe(1) + }), + ) + }) + + test("times out a hanging connect and falls back to http", async () => { + const connector: WebSocketConnector = { open: () => Effect.never } + + await runWithTestClock( + connector, + Effect.gen(function* () { + const transport = yield* SessionModelTransport.Service + const running = yield* collect(transport.bind(session), exchange("slow")).pipe( + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.yieldNow + yield* TestClock.adjust("10 seconds") + expect(yield* Fiber.join(running)).toEqual(["fallback:slow"]) + }), + ) + }) + test("times out an idle accepted request and poisons its socket", async () => { const started = Deferred.makeUnsafe() const messages = queue() From dde66b950f5753d73d237e6d05f86f756c760d23 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 17:29:24 +0530 Subject: [PATCH 3/8] fix(ai): retry incremental sends rejected without a code in full --- .../protocols/open-responses-continuation.ts | 5 +++ .../ai/test/provider/openai-responses.test.ts | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/ai/src/protocols/open-responses-continuation.ts b/packages/ai/src/protocols/open-responses-continuation.ts index 27ae3a9f83e4..195bcd657333 100644 --- a/packages/ai/src/protocols/open-responses-continuation.ts +++ b/packages/ai/src/protocols/open-responses-continuation.ts @@ -103,6 +103,7 @@ const incremental = ( } const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined +const errorType = (event: OpenResponses.Event) => event.error?.type || event.response?.error?.type || undefined const rejected = ( observation: Extract, @@ -153,6 +154,10 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => { const rejection = code(event) if (rejection === "previous_response_not_found") return rejected(observation, "retry-full") if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full") + // Only the continuation distinguishes an incremental send from a full one, so an invalid request + // there is retried full. Codex reports a stale previous_response_id with this type and no code. + if (create.mode === "incremental" && errorType(event) === "invalid_request_error") + return rejected(observation, "retry-full") } if (observation.type !== "completed") return observation // A trigger installs a different context window. Clear the append baseline, retaining the socket. diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 8dca696f7f56..3440faf8874a 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -852,6 +852,40 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("retries an incremental send in full when the provider rejects it without a code", () => + Effect.gen(function* () { + const firstRequest = { + type: "response.create", + model: "gpt-5.2", + store: false, + input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }], + } + const first = continuationDriver(firstRequest) + const saved = checkpoint( + yield* first.observe( + yield* first.create(undefined), + ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }), + ), + ) + const second = continuationDriver({ + ...firstRequest, + input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }], + }) + // Codex reports a stale previous_response_id as a plain invalid_request_error. + const stale = ProviderShared.encodeJson({ + type: "error", + error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." }, + }) + const incremental = yield* second.create(saved) + expect(incremental.mode).toBe("incremental") + expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" }) + + // A full send has no continuation to blame, so the same error stays a provider failure. + const full = yield* second.create(undefined) + expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" }) + }), + ) + it.effect("builds WebSocket and HTTP fallback from the same final request", () => Effect.gen(function* () { const attempts = yield* Ref.make(0) From 662479f1ca8084a6ffbd094652795f5b8ea642f9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 17:29:24 +0530 Subject: [PATCH 4/8] feat(core): enable the responses websocket by default --- packages/core/src/session/model-request.ts | 16 ++++++---- .../core/test/plugin/provider-openai.test.ts | 31 +++++++++---------- services/www/src/docs/content/providers.mdx | 14 +++++++++ 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 83a27358bdd4..a97566faa63c 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -27,8 +27,15 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB const IMAGE_REMOVED = "[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]" -const responsesWebSocketFlag = (providerID: string) => - `OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET` +// Enabled by default where the route supports it. `OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false` opts a provider out; +// the experimental name from the opt-in period is still honored. +const responsesWebSocket = (providerID: string) => { + const suffix = `${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET` + return Config.boolean(`OPENCODE_${suffix}`).pipe( + Config.orElse(() => Config.boolean(`OPENCODE_EXPERIMENTAL_${suffix}`)), + Config.withDefault(true), + ) +} /** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */ export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError @@ -366,10 +373,7 @@ export const layer = Layer.effect( (yield* hooks.has("session", "http.response", resolved.ref.providerID)) const webSocket = resolved.capabilities.responsesWebsockets === true - ? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe( - Config.withDefault(false), - Effect.orDie, - ) + ? yield* responsesWebSocket(resolved.ref.providerID).pipe(Effect.orDie) : false const http = hasHttpHooks ? httpMiddleware(hooks, { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 992d0295bc20..0f83521398d7 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -177,7 +177,9 @@ describe("OpenAIPlugin", () => { expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe( + false, + ) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false) }), ) @@ -218,7 +220,7 @@ describe("OpenAIPlugin", () => { }), ) - it.effect("selects Azure WebSocket from capability and the Azure flag only", () => + it.effect("selects Azure WebSocket from capability and honors the Azure opt-out only", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ @@ -269,24 +271,19 @@ describe("OpenAIPlugin", () => { Effect.provideService(SessionModelTransport.Service, transport), ) - const prepared = yield* program.pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "true" } }), - ), - ), - ) - const otherProvider = yield* program.pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }), - ), - ), - ) + const withEnv = (env: Record) => + program.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))) + + const prepared = yield* withEnv({}) + const otherProvider = yield* withEnv({ OPENCODE_OPENAI_RESPONSES_WEBSOCKET: "false" }) + const optedOut = yield* withEnv({ OPENCODE_AZURE_RESPONSES_WEBSOCKET: "false" }) + const legacyOptOut = yield* withEnv({ OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "false" }) expect(prepared.options.webSocket).toBe(executor) expect(prepared.options.http).toBeUndefined() - expect(otherProvider.options.webSocket).toBeUndefined() + expect(otherProvider.options.webSocket).toBe(executor) + expect(optedOut.options.webSocket).toBeUndefined() + expect(legacyOptOut.options.webSocket).toBeUndefined() }), ) }) diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index d1ee33d390ab..7d1465deffdc 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -108,6 +108,20 @@ Your identity needs the **Cognitive Services OpenAI User** role for Azure OpenAI role for other Foundry models. If a request fails because the token belongs to another tenant, sign in again with `az login --tenant TENANT_ID`. +## WebSocket transport + +OpenAI, Azure, and xAI Responses models keep one WebSocket connection open per session and send each step over it +instead of opening a new HTTP request. Consecutive steps only transmit what changed since the previous response, +which shortens time to first token and reduces upload volume on long sessions. Provider compaction runs over the same +connection. + +The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot +be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a +provider keep it on HTTP so the hooks observe every request. + +Set `OPENCODE__RESPONSES_WEBSOCKET=false` to keep a provider on HTTP, for example +`OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false`. + ## Endpoint Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package, From 76cc9a8c79571bb8d86015b171850962aa193e58 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 18:09:04 +0530 Subject: [PATCH 5/8] feat(core): add the websocket provider policy --- .../client/src/promise/generated/types.ts | 4 ++ packages/core/src/catalog.ts | 1 + packages/core/src/config/plugin/provider.ts | 2 + packages/core/src/model-resolver.ts | 3 + packages/core/src/session/model-request.ts | 6 +- packages/core/src/session/runner/model.ts | 2 + packages/core/test/config/provider.test.ts | 27 ++++++++ .../core/test/plugin/provider-openai.test.ts | 65 ++++++++++--------- packages/schema/src/config/provider.ts | 6 ++ packages/schema/src/model.ts | 1 + packages/schema/src/provider.ts | 2 + services/www/src/docs/content/config.mdx | 17 +++++ services/www/src/docs/content/providers.mdx | 15 ++++- 13 files changed, 117 insertions(+), 34 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 46b74aaa13dc..c6206bc813ba 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1369,6 +1369,7 @@ export type ProviderInfo = { activation: "auto" | "enabled" | "disabled" package: string compaction?: ProviderCompaction + websocket?: boolean settings?: { [x: string]: any } headers?: { [x: string]: string } body?: { [x: string]: any } @@ -1849,6 +1850,7 @@ export type ModelInfo = { compatibility?: ModelCompatibility package?: string compaction?: ProviderCompaction + websocket?: boolean settings?: { [x: string]: any } headers?: { [x: string]: string } body?: { [x: string]: any } @@ -2025,6 +2027,7 @@ export type ConfigEntry = providers?: { [x: string]: { compaction?: ProviderCompaction + websocket?: boolean canonical?: string name?: string env?: Array @@ -2035,6 +2038,7 @@ export type ConfigEntry = models?: { [x: string]: { compaction?: ProviderCompaction + websocket?: boolean modelID?: string family?: string name?: string diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index e946e21bd9c9..368f21e5b52b 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -77,6 +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, settings: Provider.mergeOverlay(provider.settings, model.settings), headers: Provider.mergeHeaders(provider.headers, model.headers), body: Provider.mergeOverlay(provider.body, model.body), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index e9022cf9c834..250c9399e233 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -58,6 +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.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) @@ -78,6 +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.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) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index c8e9582c8b09..94ef440f043a 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -85,6 +85,8 @@ export interface Resolved { readonly limit: Info["limit"] /** Model policy overrides the provider policy; omitted means local compaction. */ readonly compaction?: Info["compaction"] + /** Model policy overrides the provider policy; omitted means the WebSocket transport is used where supported. */ + readonly websocket?: Info["websocket"] } export interface Interface { @@ -321,6 +323,7 @@ export const layer = Layer.effect( cost: selected.cost, limit: selected.limit, compaction: selected.compaction, + websocket: selected.websocket, } }) return Service.of({ diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index a97566faa63c..0b021ecd3ad9 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -27,8 +27,8 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB const IMAGE_REMOVED = "[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]" -// Enabled by default where the route supports it. `OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false` opts a provider out; -// the experimental name from the opt-in period is still honored. +// Enabled by default where the route supports it. `providers..websocket: false` or +// `OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false` opts out; the experimental name from the opt-in period is still honored. const responsesWebSocket = (providerID: string) => { const suffix = `${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET` return Config.boolean(`OPENCODE_${suffix}`).pipe( @@ -372,7 +372,7 @@ export const layer = Layer.effect( (yield* hooks.has("session", "http.request", resolved.ref.providerID)) || (yield* hooks.has("session", "http.response", resolved.ref.providerID)) const webSocket = - resolved.capabilities.responsesWebsockets === true + resolved.capabilities.responsesWebsockets === true && resolved.websocket !== false ? yield* responsesWebSocket(resolved.ref.providerID).pipe(Effect.orDie) : false const http = hasHttpHooks diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index a6f6734d41b7..f3363084d396 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -60,6 +60,7 @@ export const resolved = ( readonly cost: Model.Info["cost"] readonly limit: Model.Info["limit"] readonly compaction?: Provider.Compaction + readonly websocket?: boolean }, ): Resolved => ({ model, @@ -72,6 +73,7 @@ export const resolved = ( cost: options.cost, limit: options.limit, compaction: options.compaction, + websocket: options.websocket, }) const layer = Layer.effect( diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 5b129c2a7696..08bfd9da7388 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -80,6 +80,33 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("inherits the provider websocket policy with model overrides", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin([ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "@opencode/ai/providers/openai/responses", + websocket: false, + models: { inherited: {}, override: { websocket: true } }, + }, + default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } }, + }, + }), + }), + ]) + 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() + }), + ) + it.effect("adds key auth for custom providers without env credentials", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 0f83521398d7..116f00357d70 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -241,49 +241,54 @@ describe("OpenAIPlugin", () => { id: "deployment-responses", provider: Provider.ID.azure, }) - const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { - capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true }, - cost: [], - limit: { context: 200_000, output: 32_000 }, - }) - const program = Effect.gen(function* () { - const requests = yield* SessionModelRequest.Service - return yield* requests.prepare({ - kind: "primary", - scope: { - session: Session.Info.make({ - id: sessionID, - projectID: Project.ID.global, - cost: Money.USD.zero, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), - }), - agentID, - model, - tools: { definitions: [], execute: () => Effect.die("unused tool execution") }, - }, - transcript: { system: [], messages: [] }, - webSocket: "session", + const resolved = (websocket?: boolean) => + SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { + capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + websocket, }) - }).pipe( - Effect.provide(SessionModelRequest.layer), - Effect.provideService(SessionModelTransport.Service, transport), - ) + const program = (model: SessionRunnerModel.Resolved) => + Effect.gen(function* () { + const requests = yield* SessionModelRequest.Service + return yield* requests.prepare({ + kind: "primary", + scope: { + session: Session.Info.make({ + id: sessionID, + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), + }), + agentID, + model, + tools: { definitions: [], execute: () => Effect.die("unused tool execution") }, + }, + transcript: { system: [], messages: [] }, + webSocket: "session", + }) + }).pipe( + Effect.provide(SessionModelRequest.layer), + Effect.provideService(SessionModelTransport.Service, transport), + ) - const withEnv = (env: Record) => - program.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))) + const withEnv = (env: Record, model = resolved()) => + program(model).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))) const prepared = yield* withEnv({}) const otherProvider = yield* withEnv({ OPENCODE_OPENAI_RESPONSES_WEBSOCKET: "false" }) const optedOut = yield* withEnv({ OPENCODE_AZURE_RESPONSES_WEBSOCKET: "false" }) const legacyOptOut = yield* withEnv({ OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "false" }) + const configuredOff = yield* withEnv({}, resolved(false)) expect(prepared.options.webSocket).toBe(executor) expect(prepared.options.http).toBeUndefined() expect(otherProvider.options.webSocket).toBe(executor) expect(optedOut.options.webSocket).toBeUndefined() expect(legacyOptOut.options.webSocket).toBeUndefined() + expect(configuredOff.options.webSocket).toBeUndefined() }), ) }) diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index 56b1db48db52..e6a3645a3508 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -42,6 +42,9 @@ class Limit extends Schema.Class("Config.Model.Limit")({ class Model extends Schema.Class("Config.Model")({ compaction: Provider.Compaction.pipe(optional), + websocket: Schema.Boolean.pipe(optional).annotate({ + description: "Use the provider's WebSocket transport for this model when the route supports it. Defaults to true.", + }), modelID: ID.pipe(optional), family: Family.pipe(optional), name: Schema.String.pipe(optional), @@ -60,6 +63,9 @@ class Model extends Schema.Class("Config.Model")({ export class Info extends Schema.Class("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.", + }), canonical: Provider.ID.pipe(optional), name: Schema.String.pipe(optional), env: Schema.String.pipe(Schema.Array, optional), diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 50abfa7ac7da..f96c6ed42f5a 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -107,6 +107,7 @@ export const Info = Schema.Struct({ compatibility: Compatibility.pipe(optional), package: Provider.Package.pipe(optional), compaction: Provider.Compaction.pipe(optional), + websocket: Schema.Boolean.pipe(optional), ...Provider.Overlays, capabilities: Capabilities, variants: Schema.Array(Variant), diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index ffad36211ca5..47a74c9bc7da 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -59,6 +59,8 @@ 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), ...Overlays, }) .annotate({ identifier: "Provider.Info" }) diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx index 7957b64bc199..500ae84e0ca0 100644 --- a/services/www/src/docs/content/config.mdx +++ b/services/www/src/docs/content/config.mdx @@ -378,6 +378,23 @@ Top-level `compaction.auto: false` disables new automatic compaction without discarding installed checkpoints. See the [compaction guide](/compaction) for budgeting and overflow recovery. +Providers whose routes support it (OpenAI, Azure, and xAI Responses) keep one +WebSocket connection open per session. `websocket: false` on a provider or model +keeps it on HTTP instead; a model policy overrides the provider policy: + +```jsonc +{ + "providers": { + "openai": { + "websocket": false, + "models": { + "gpt-5.5": { "websocket": true }, + }, + }, + }, +} +``` + ### Session warming Keep recently active model sessions warm with periodic transient requests. diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index 7d1465deffdc..5be00237f4fa 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -119,7 +119,20 @@ The connection is transparent. When the provider closes the socket, the next ste be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a provider keep it on HTTP so the hooks observe every request. -Set `OPENCODE__RESPONSES_WEBSOCKET=false` to keep a provider on HTTP, for example +Set `websocket: false` on a provider or model to keep it on HTTP: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "providers": { + "openai": { + "websocket": false, + }, + }, +} +``` + +The environment variable `OPENCODE__RESPONSES_WEBSOCKET=false` does the same for one process, for example `OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false`. ## Endpoint From c45d3af0dae97f2bfe1014aa69f48527103bb0e7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 19:01:49 +0530 Subject: [PATCH 6/8] fix(ai): keep classified failures out of continuation recovery --- .../protocols/open-responses-continuation.ts | 12 +++-- .../ai/test/provider/openai-responses.test.ts | 50 ++++++++++++++++--- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/protocols/open-responses-continuation.ts b/packages/ai/src/protocols/open-responses-continuation.ts index 195bcd657333..38dd3e95a7f1 100644 --- a/packages/ai/src/protocols/open-responses-continuation.ts +++ b/packages/ai/src/protocols/open-responses-continuation.ts @@ -103,7 +103,6 @@ const incremental = ( } const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined -const errorType = (event: OpenResponses.Event) => event.error?.type || event.response?.error?.type || undefined const rejected = ( observation: Extract, @@ -154,9 +153,14 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => { const rejection = code(event) if (rejection === "previous_response_not_found") return rejected(observation, "retry-full") if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full") - // Only the continuation distinguishes an incremental send from a full one, so an invalid request - // there is retried full. Codex reports a stale previous_response_id with this type and no code. - if (create.mode === "incremental" && errorType(event) === "invalid_request_error") + // Only the continuation distinguishes an incremental send from a full one, so an unclassified + // invalid request there is retried full; Codex reports a stale previous_response_id that way, with + // no code. Classified failures such as context overflow keep their runner-owned recovery. + if ( + create.mode === "incremental" && + observation.error.reason._tag === "InvalidRequest" && + observation.error.reason.classification === undefined + ) return rejected(observation, "retry-full") } if (observation.type !== "completed") return observation diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 3440faf8874a..b9e9fccf7bce 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect" +import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" import { LLM, @@ -30,6 +30,7 @@ import * as Azure from "../../src/providers/azure.js" import * as OpenAI from "../../src/providers/openai.js" import * as XAI from "../../src/providers/xai.js" import * as OpenAIResponses from "../../src/protocols/openai-responses.js" +import { OpenResponses } from "../../src/protocols/open-responses.js" import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js" import * as ProviderShared from "../../src/protocols/shared.js" import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js" @@ -69,14 +70,34 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({ }, }) -const continuationDriver = (request: Readonly>) => { +/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */ +const classifyingChannelDriver = (message: string): WebSocketChannelDriver => { + const base = baseChannelDriver(message) + const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event) + return { + ...base, + observe: (create, frame) => + base.observe(create, frame).pipe( + Effect.map((observation) => + observation.type === "provider-failure" + ? { + ...observation, + error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame), + } + : observation, + ), + ), + } +} + +const continuationDriver = (request: Readonly>, base = baseChannelDriver) => { const message = ProviderShared.encodeJson(request) return OpenResponsesContinuation.driver({ id: "openai-responses", name: "OpenAI Responses", request, message, - base: baseChannelDriver(message), + base: base(message), }) } @@ -860,17 +881,20 @@ describe("OpenAI Responses route", () => { store: false, input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }], } - const first = continuationDriver(firstRequest) + const first = continuationDriver(firstRequest, classifyingChannelDriver) const saved = checkpoint( yield* first.observe( yield* first.create(undefined), ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }), ), ) - const second = continuationDriver({ - ...firstRequest, - input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }], - }) + const second = continuationDriver( + { + ...firstRequest, + input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }], + }, + classifyingChannelDriver, + ) // Codex reports a stale previous_response_id as a plain invalid_request_error. const stale = ProviderShared.encodeJson({ type: "error", @@ -883,6 +907,16 @@ describe("OpenAI Responses route", () => { // A full send has no continuation to blame, so the same error stays a provider failure. const full = yield* second.create(undefined) expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" }) + + // A classified failure keeps its runner-owned recovery instead of resending the whole context. + const overflow = ProviderShared.encodeJson({ + type: "error", + error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" }, + }) + expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({ + type: "provider-failure", + error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } }, + }) }), ) From f0957d77914306227dee6c582eb0c46c557af7c1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 19:01:50 +0530 Subject: [PATCH 7/8] fix(ai): keep an error listener on an aborted websocket handshake --- packages/ai/src/route/transport/websocket.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/route/transport/websocket.ts b/packages/ai/src/route/transport/websocket.ts index b4b7e03e8a6f..bce2d28a6bc4 100644 --- a/packages/ai/src/route/transport/websocket.ts +++ b/packages/ai/src/route/transport/websocket.ts @@ -115,8 +115,11 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { } const onAbort = () => { cleanup() - if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING) - ws.close(1000) + if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return + // Node's ws reports an aborted handshake as an error event on the next tick; with no listener left + // after cleanup, EventEmitter would throw it as an uncaught exception. + ws.addEventListener("error", () => {}, { once: true }) + ws.close(1000) } const onOpen = () => { cleanup() From f0e481babb2c50ed044e55bd140ad06210a29ed2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 19:02:19 +0530 Subject: [PATCH 8/8] refactor(core): decide the websocket transport from the catalog policy --- packages/core/src/model-resolver.ts | 6 +- packages/core/src/session/model-request.ts | 22 ++----- packages/core/src/session/model-transport.ts | 33 +++++------ packages/core/src/session/runner/model.ts | 2 +- packages/core/src/session/runner/retry.ts | 4 +- packages/core/test/generate.test.ts | 1 + .../core/test/plugin/provider-openai.test.ts | 36 ++++-------- .../core/test/session-model-transport.test.ts | 58 +++++-------------- packages/schema/src/config/provider.ts | 2 +- packages/schema/src/model.ts | 1 + services/www/src/docs/content/config.mdx | 22 ++----- services/www/src/docs/content/providers.mdx | 12 ++-- 12 files changed, 68 insertions(+), 131 deletions(-) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 94ef440f043a..3755ff94152d 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -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"] - /** Model policy overrides the provider policy; omitted means the WebSocket transport is used where supported. */ - readonly websocket?: Info["websocket"] + /** Whether the session WebSocket may carry this model's requests when the route supports it. */ + readonly websocket: boolean } export interface Interface { @@ -323,7 +323,7 @@ export const layer = Layer.effect( cost: selected.cost, limit: selected.limit, compaction: selected.compaction, - websocket: selected.websocket, + websocket: selected.websocket ?? true, } }) return Service.of({ diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 0b021ecd3ad9..690abe7dfc1b 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -6,7 +6,7 @@ import type { SessionRequestKind } from "@opencode/plugin/effect/session" import type { Agent } from "@opencode/schema/agent" import type { Model } from "@opencode/schema/model" import type { Content } from "@opencode/schema/tool" -import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect" +import { Cause, Context, Effect, Layer, Result, Stream } from "effect" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { makeLocationNode } from "@opencode/util/effect/app-node" import { App } from "../app.js" @@ -27,16 +27,6 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB const IMAGE_REMOVED = "[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]" -// Enabled by default where the route supports it. `providers..websocket: false` or -// `OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false` opts out; the experimental name from the opt-in period is still honored. -const responsesWebSocket = (providerID: string) => { - const suffix = `${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET` - return Config.boolean(`OPENCODE_${suffix}`).pipe( - Config.orElse(() => Config.boolean(`OPENCODE_EXPERIMENTAL_${suffix}`)), - Config.withDefault(true), - ) -} - /** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */ export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError @@ -371,10 +361,6 @@ export const layer = Layer.effect( const hasHttpHooks = (yield* hooks.has("session", "http.request", resolved.ref.providerID)) || (yield* hooks.has("session", "http.response", resolved.ref.providerID)) - const webSocket = - resolved.capabilities.responsesWebsockets === true && resolved.websocket !== false - ? yield* responsesWebSocket(resolved.ref.providerID).pipe(Effect.orDie) - : false const http = hasHttpHooks ? httpMiddleware(hooks, { sessionID: session.id, @@ -383,9 +369,13 @@ export const layer = Layer.effect( kind: input.kind, }) : undefined + // HTTP hooks must observe every request, so they keep the provider on HTTP. const options: StreamOptions = { ...(http ? { http } : {}), - ...(input.webSocket === "session" && webSocket && !hasHttpHooks + ...(input.webSocket === "session" && + !hasHttpHooks && + resolved.capabilities.responsesWebsockets === true && + resolved.websocket ? { webSocket: transport.bind(session.id) } : {}), } diff --git a/packages/core/src/session/model-transport.ts b/packages/core/src/session/model-transport.ts index df46bf114562..28df22a7bb73 100644 --- a/packages/core/src/session/model-transport.ts +++ b/packages/core/src/session/model-transport.ts @@ -308,23 +308,22 @@ export const makeLayer = (connector: WebSocketConnector) => const channel = owner.channel ? owner.channel : yield* open(owner, exchange, key).pipe( - Effect.catch((error) => - error.reason._tag === "Transport" && error.reason.code === "owner-closed" - ? Effect.fail(error) - : Effect.logWarning("session websocket connect failed; using http", { - sessionTransport: "websocket", - phase: "connect", - delivery: "not-sent", - code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag, - }).pipe( - Effect.andThen(metric("connect_failure")), - Effect.andThen(metric("fallback")), - // A network that refuses the upgrade would otherwise charge every step for a failed - // connect; the Session stays on HTTP for the rest of this process. - Effect.tap(() => Effect.sync(() => (owner.httpFallback = true))), - Effect.as(undefined), - ), - ), + Effect.catch((error) => { + if (error.reason._tag === "Transport" && error.reason.code === "owner-closed") return Effect.fail(error) + // Any connect failure, transient or not, pins the Session to HTTP until restart or move: + // a network that refuses the upgrade would otherwise charge every step for a failed connect. + owner.httpFallback = true + return Effect.logWarning("session websocket connect failed; using http", { + sessionTransport: "websocket", + phase: "connect", + delivery: "not-sent", + code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag, + }).pipe( + Effect.andThen(metric("connect_failure")), + Effect.andThen(metric("fallback")), + Effect.as(undefined), + ) + }), ) if (!channel) return fallback(exchange) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index f3363084d396..66fce538675f 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -73,7 +73,7 @@ export const resolved = ( cost: options.cost, limit: options.limit, compaction: options.compaction, - websocket: options.websocket, + websocket: options.websocket ?? true, }) const layer = Layer.effect( diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index cbadaaf525f0..ca5f1b59d04a 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -35,8 +35,8 @@ export function isRetryable(error: AIError) { case "RateLimit": case "ProviderInternal": return true - // HTTP never learns whether the provider saw a request and always retries. WebSocket does: - // only a request the provider accepted or rejected is final; an ambiguous send is pre-output. + // HTTP transport errors carry no delivery and always retry. WebSocket marks accepted and rejected + // requests as final; not-sent and ambiguous (no frame observed) are still pre-output. case "Transport": return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected" case "InvalidProviderOutput": diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index a85ae654593f..1bc31eb48c67 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -94,6 +94,7 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () => capabilities: selected.capabilities, cost: selected.cost, limit: selected.limit, + websocket: true, }) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 116f00357d70..c24745a166e6 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -177,9 +177,7 @@ describe("OpenAIPlugin", () => { expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe( - false, - ) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false) }), ) @@ -220,7 +218,7 @@ describe("OpenAIPlugin", () => { }), ) - it.effect("selects Azure WebSocket from capability and honors the Azure opt-out only", () => + it.effect("selects Azure WebSocket from capability unless the policy disables it", () => Effect.gen(function* () { const credentials = yield* Credential.Service yield* credentials.create({ @@ -241,15 +239,14 @@ describe("OpenAIPlugin", () => { id: "deployment-responses", provider: Provider.ID.azure, }) - const resolved = (websocket?: boolean) => - SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { - capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true }, - cost: [], - limit: { context: 200_000, output: 32_000 }, - websocket, - }) - const program = (model: SessionRunnerModel.Resolved) => + const prepare = (websocket?: boolean) => Effect.gen(function* () { + const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { + capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + websocket, + }) const requests = yield* SessionModelRequest.Service return yield* requests.prepare({ kind: "primary", @@ -274,21 +271,12 @@ describe("OpenAIPlugin", () => { Effect.provideService(SessionModelTransport.Service, transport), ) - const withEnv = (env: Record, model = resolved()) => - program(model).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))) - - const prepared = yield* withEnv({}) - const otherProvider = yield* withEnv({ OPENCODE_OPENAI_RESPONSES_WEBSOCKET: "false" }) - const optedOut = yield* withEnv({ OPENCODE_AZURE_RESPONSES_WEBSOCKET: "false" }) - const legacyOptOut = yield* withEnv({ OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "false" }) - const configuredOff = yield* withEnv({}, resolved(false)) + const prepared = yield* prepare() + const disabled = yield* prepare(false) expect(prepared.options.webSocket).toBe(executor) expect(prepared.options.http).toBeUndefined() - expect(otherProvider.options.webSocket).toBe(executor) - expect(optedOut.options.webSocket).toBeUndefined() - expect(legacyOptOut.options.webSocket).toBeUndefined() - expect(configuredOff.options.webSocket).toBeUndefined() + expect(disabled.options.webSocket).toBeUndefined() }), ) }) diff --git a/packages/core/test/session-model-transport.test.ts b/packages/core/test/session-model-transport.test.ts index aad447263ee3..ccc95c4618a0 100644 --- a/packages/core/test/session-model-transport.test.ts +++ b/packages/core/test/session-model-transport.test.ts @@ -526,40 +526,6 @@ describe("SessionModelTransport", () => { ) }) - test("keeps a Session on http after a failed connect", async () => { - let attempts = 0 - const connector: WebSocketConnector = { - open: () => - Effect.sync(() => attempts++).pipe( - Effect.andThen( - Effect.fail( - new AIError({ - reason: new TransportError({ - message: "upgrade rejected", - transport: "websocket", - operation: "request", - phase: "connect", - delivery: "not-sent", - }), - }), - ), - ), - ), - } - - await run( - connector, - Effect.gen(function* () { - const transport = yield* SessionModelTransport.Service - const executor = transport.bind(session) - expect(yield* collect(executor, exchange("first"))).toEqual(["fallback:first"]) - expect(yield* collect(executor, exchange("second"))).toEqual(["fallback:second"]) - // One failed upgrade per Session, not one per step. - expect(attempts).toBe(1) - }), - ) - }) - test("times out a hanging connect and falls back to http", async () => { const connector: WebSocketConnector = { open: () => Effect.never } @@ -680,25 +646,31 @@ describe("SessionModelTransport", () => { ) }) - test("falls back once when connection setup fails before send", async () => { + test("falls back when connection setup fails and keeps the Session on HTTP", async () => { + let attempts = 0 let fallbacks = 0 - const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) } + const connector: WebSocketConnector = { + open: () => + Effect.sync(() => attempts++).pipe(Effect.andThen(Effect.fail(error("upgrade rejected", "not-sent")))), + } await run( connector, Effect.gen(function* () { const transport = yield* SessionModelTransport.Service - const result = yield* collect( - transport.bind(session), - exchange("first", { + const executor = transport.bind(session) + const item = (id: string) => + exchange(id, { fallback: () => { fallbacks++ return Stream.make("http") }, - }), - ) - expect(result).toEqual(["http"]) - expect(fallbacks).toBe(1) + }) + expect(yield* collect(executor, item("first"))).toEqual(["http"]) + expect(yield* collect(executor, item("second"))).toEqual(["http"]) + // One failed upgrade per Session, not one per step. + expect(attempts).toBe(1) + expect(fallbacks).toBe(2) }), ) }) diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index e6a3645a3508..b1a669b1d529 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -43,7 +43,7 @@ class Limit extends Schema.Class("Config.Model.Limit")({ class Model extends Schema.Class("Config.Model")({ compaction: Provider.Compaction.pipe(optional), websocket: Schema.Boolean.pipe(optional).annotate({ - description: "Use the provider's WebSocket transport for this model when the route supports it. Defaults to true.", + description: "Use the provider's WebSocket transport for this model. Defaults to the provider policy.", }), modelID: ID.pipe(optional), family: Family.pipe(optional), diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index f96c6ed42f5a..d1fe3abf95b0 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -107,6 +107,7 @@ 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), ...Provider.Overlays, capabilities: Capabilities, diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx index 500ae84e0ca0..286ce6031203 100644 --- a/services/www/src/docs/content/config.mdx +++ b/services/www/src/docs/content/config.mdx @@ -378,23 +378,6 @@ Top-level `compaction.auto: false` disables new automatic compaction without discarding installed checkpoints. See the [compaction guide](/compaction) for budgeting and overflow recovery. -Providers whose routes support it (OpenAI, Azure, and xAI Responses) keep one -WebSocket connection open per session. `websocket: false` on a provider or model -keeps it on HTTP instead; a model policy overrides the provider policy: - -```jsonc -{ - "providers": { - "openai": { - "websocket": false, - "models": { - "gpt-5.5": { "websocket": true }, - }, - }, - }, -} -``` - ### Session warming Keep recently active model sessions warm with periodic transient requests. @@ -555,4 +538,7 @@ headers, and model variants. } ``` -See the [providers guide](/providers) for credentials, custom endpoints, provider packages, and model configuration. +`websocket: false` on a provider or model keeps it on HTTP instead of the +session WebSocket; a model policy overrides the provider policy. + +See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration. diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index 5be00237f4fa..75c83cae6485 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -111,15 +111,15 @@ role for other Foundry models. If a request fails because the token belongs to a ## WebSocket transport OpenAI, Azure, and xAI Responses models keep one WebSocket connection open per session and send each step over it -instead of opening a new HTTP request. Consecutive steps only transmit what changed since the previous response, -which shortens time to first token and reduces upload volume on long sessions. Provider compaction runs over the same +instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit what was +added since the previous response, which cuts upload volume on long sessions. Provider compaction runs over the same connection. The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a provider keep it on HTTP so the hooks observe every request. -Set `websocket: false` on a provider or model to keep it on HTTP: +Set `websocket: false` on a provider or model to keep it on HTTP; a model policy overrides the provider policy: ```jsonc title="opencode.jsonc" { @@ -127,14 +127,14 @@ Set `websocket: false` on a provider or model to keep it on HTTP: "providers": { "openai": { "websocket": false, + "models": { + "gpt-5.5": { "websocket": true }, + }, }, }, } ``` -The environment variable `OPENCODE__RESPONSES_WEBSOCKET=false` does the same for one process, for example -`OPENCODE_OPENAI_RESPONSES_WEBSOCKET=false`. - ## Endpoint Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package,