diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts index 08c17bf719..8fa2f125c9 100644 --- a/packages/opencode/src/plugin/openai/ws.ts +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -2,6 +2,7 @@ // https://github.com/vercel-labs/ai-sdk-openai-websocket/blob/main/packages/ai-sdk-openai-websocket-fetch/src/index.ts import WebSocket from "ws" +import { ProviderTransport } from "@/provider/transport" export interface CreateWebSocketFetchOptions { /** @@ -75,23 +76,31 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url + const internalHeaders = normalizeHeaders(init?.headers) + const httpInit = ProviderTransport.withoutInternalHeaders(init) if (init?.method !== "POST" || !url.endsWith("/responses")) { - return globalThis.fetch(input, init) + return globalThis.fetch(input, httpInit) } let body: Record try { body = JSON.parse(typeof init.body === "string" ? init.body : "") } catch { - return globalThis.fetch(input, init) + return globalThis.fetch(input, httpInit) + } + + // Temporary title-generation split: title requests share the conversation session ID today, + // so do not let them occupy or mutate the conversation WebSocket pool. + if (internalHeaders[ProviderTransport.INTERNAL_TRANSPORT_PURPOSE_HEADER] === ProviderTransport.PURPOSE.title) { + return globalThis.fetch(input, httpInit) } if (!body.stream) { - return globalThis.fetch(input, init) + return globalThis.fetch(input, httpInit) } - const headers = normalizeHeaders(init.headers) + const headers = normalizeHeaders(httpInit?.headers) delete headers["content-length"] headers["openai-beta"] ??= "responses_websockets=2026-02-06" const wsUrl = options?.url ?? url.replace(/^http/, "ws") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 496a2f6d2d..ffdcd7ba3b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -25,6 +25,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" +import { ProviderTransport } from "./transport" import { ModelID, ProviderID } from "./schema" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -1617,6 +1618,11 @@ export const layer = Layer.effect( const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) if (combined) opts.signal = combined + // The WebSocket transport temporarily receives opencode-only routing metadata through + // headers because provider SDKs are still created below this layer. Never forward that + // metadata to upstream providers; replace this once llm.ts constructs SDK models directly. + if (opts.headers) opts.headers = ProviderTransport.stripInternalHeaders(opts.headers) + // Strip openai itemId metadata following what codex does if ( (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") && diff --git a/packages/opencode/src/provider/transport.ts b/packages/opencode/src/provider/transport.ts new file mode 100644 index 0000000000..ef1e788896 --- /dev/null +++ b/packages/opencode/src/provider/transport.ts @@ -0,0 +1,39 @@ +export const INTERNAL_TRANSPORT_PURPOSE_HEADER = "x-opencode-internal-transport-purpose" + +export const PURPOSE = { + conversation: "conversation", + title: "title", +} as const + +export type Purpose = (typeof PURPOSE)[keyof typeof PURPOSE] + +export function purposeForAgent(agent: string): Purpose { + if (agent === "title") return PURPOSE.title + return PURPOSE.conversation +} + +export function withoutInternalHeaders(init: T | undefined): T | undefined { + if (!init?.headers) return init + return { + ...init, + headers: stripInternalHeaders(init.headers), + } +} + +export function stripInternalHeaders(headers: HeadersInit): HeadersInit { + if (headers instanceof Headers) { + const next = new Headers(headers) + next.delete(INTERNAL_TRANSPORT_PURPOSE_HEADER) + return next + } + + if (Array.isArray(headers)) { + return headers.filter((item) => item[0].toLowerCase() !== INTERNAL_TRANSPORT_PURPOSE_HEADER) + } + + return Object.fromEntries( + Object.entries(headers).filter(([key]) => key.toLowerCase() !== INTERNAL_TRANSPORT_PURPOSE_HEADER), + ) +} + +export * as ProviderTransport from "./transport" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 3471342405..8d53e0f998 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -5,6 +5,7 @@ import { Permission } from "@/permission" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "../message-v2" import type { Provider } from "@/provider/provider" +import { ProviderTransport } from "@/provider/transport" import { ProviderTransform } from "@/provider/transform" import { SystemPrompt } from "../system" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -181,6 +182,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }), ...input.model.headers, ...headers, + // Temporary fetch-layer hack for the OpenAI WebSocket pool. Title generation currently + // shares the conversation session ID, so this lets transport split it out until llm.ts + // can pass real transport context without smuggling state through request headers. + [ProviderTransport.INTERNAL_TRANSPORT_PURPOSE_HEADER]: ProviderTransport.purposeForAgent(input.agent.name), }, } })