diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d9ec1fb01a..e2b72cfb0c 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1966,10 +1966,10 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { } /** - * Muse Spark ids whose Responses gateway refuses `search_content_types` on a plain + * Muse Spark ids whose Responses gateway refuses provider-specific fields on a plain * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the * same-shaped successor to 1.2 on the same Zen wire, and an equality check would - * have let a Codex-emitted `web_search` + `search_content_types` body reach the + * have let a Codex-emitted `web_search` body reach the * gateway and come back 400 for every request the moment 1.3 was selected. */ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ @@ -1977,14 +1977,16 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.2-contributor", ]); +const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ + "search_content_types", + "indexed_web_access", +] as const; + /** - * OpenCode Zen / Go Muse Spark Responses gateway refuses `search_content_types` - * on a plain `web_search` tool (400) but accepts it on `web_search_preview`; a - * plain `web_search` is also accepted. Probed directly against the gateway on - * 2026-08-26: `web_search` + `search_content_types` -> 400, `web_search_preview` - * + `search_content_types` -> 200, plain `web_search` -> 200. Luna accepts every - * shape, so this is Muse-only. Drop only the field the gateway refuses while - * keeping the tool type and every other accepted option intact. + * OpenCode Zen / Go Muse Spark Responses gateway refuses a short list of Codex + * `web_search` fields. `web_search_preview` keeps its accepted shape, and Luna + * remains untouched. Keep the rejected names together so a newly identified field + * is a one-line compatibility update rather than another bespoke rewrite. */ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown { if (!isPlainObject(body)) return body; @@ -1995,8 +1997,11 @@ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknow let changed = false; const rewritten = tools.map(tool => { if (!isPlainObject(tool) || tool.type !== "web_search") return tool; - if (!Object.hasOwn(tool, "search_content_types")) return tool; - const { search_content_types: _dropped, ...rest } = tool; + if (!MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) { + return tool; + } + const rest = { ...tool }; + for (const field of MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS) delete rest[field]; changed = true; return rest; }); diff --git a/src/providers/opencode-go-transport.ts b/src/providers/opencode-go-transport.ts new file mode 100644 index 0000000000..a863d24f37 --- /dev/null +++ b/src/providers/opencode-go-transport.ts @@ -0,0 +1,41 @@ +import { createHash } from "node:crypto"; +import type { OcxProviderConfig } from "../types"; +import { registryEntryForProviderDestination } from "./registry"; + +export const OPENCODE_GO_SESSION_HEADER = "x-opencode-session"; + +function hasHeaderCaseInsensitive( + headers: Record | undefined, + name: string, +): boolean { + const target = name.toLowerCase(); + return Object.keys(headers ?? {}).some(key => key.toLowerCase() === target); +} + +/** Derive a provider-scoped opaque value without exposing Codex task or subagent ids. */ +export function deriveOpenCodeGoSessionId(sessionLane: string): string { + const digest = createHash("sha256") + .update("opencodex/opencode-go/session/v1\0") + .update(sessionLane) + .digest("hex") + .slice(0, 32); + return `ocx_${digest}`; +} + +/** Add per-conversation Go affinity only to the canonical fixed-key destination. */ +export function resolveOpenCodeGoTransport( + provider: T, + sessionLane: string | undefined, +): T { + if (registryEntryForProviderDestination(provider)?.id !== "opencode-go") return provider; + if (!sessionLane) return provider; + if (hasHeaderCaseInsensitive(provider.headers, OPENCODE_GO_SESSION_HEADER)) return provider; + + return { + ...provider, + headers: { + ...(provider.headers ?? {}), + [OPENCODE_GO_SESSION_HEADER]: deriveOpenCodeGoSessionId(sessionLane), + }, + }; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e257a02f15..36ee3fe9c0 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -241,6 +241,7 @@ import { } from "../../providers/key-failover"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport"; +import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport"; import type { WsData } from "../ws-bridge"; import { codexAccountSelectionForTurn, @@ -287,6 +288,7 @@ import { conversationIdFromResponsesRequest, normalizeLogConversationId, reasoningReplayConversationIdFromResponsesRequest, + sessionLaneIdFromRequest, sessionIdHeaderFromRequest, } from "../request-log-conversation"; import type { AttemptRecoveryKind } from "../../usage/log"; @@ -2059,6 +2061,7 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). + route.provider = resolveOpenCodeGoTransport(route.provider, sessionLaneIdFromRequest(req.headers)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c73e7ff5c4..a5acb84f7b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -305,6 +305,15 @@ does not set `modelResponsesUpstreamStreaming`: client `stream: true` remains re streaming until a current-runtime reproduction justifies a separate bounded-JSON compatibility policy. +The canonical OpenCode Go transport also derives `x-opencode-session` from the existing hashed +session lane before per-model wire selection. One conversation keeps one opaque affinity value +across Responses, Chat, retries, and key rotation, while sibling subagents remain distinct. An +operator-supplied header wins case-insensitively. Renamed providers are covered only when their +fixed key-auth destination still matches the registry; custom and lookalike URLs receive nothing. +Muse Spark's Responses sanitizer also drops the provider-rejected `search_content_types` and +`indexed_web_access` fields from plain `web_search` tools while preserving preview tools and +unrelated models. + [Decision Log] - 목적과 의도: Match OpenCode Go's model-specific Luna endpoint without changing sibling model behavior. - 기존 구현 및 제약 조건: The preset had one Chat default even though the upstream publishes a mixed Chat, Responses, and Anthropic matrix; operators must retain explicit override precedence. @@ -313,6 +322,14 @@ policy. - 다른 대안 대신 이 방식을 선택한 이유: The endpoint mismatch is reproducible from current code and upstream documentation, whereas a current-dev live canary has not established the separate terminal-delivery policy. - 장점, 단점 및 영향: Luna reaches its documented endpoint across inbound surfaces and explicit opt-out still works; any future stream workaround remains a separately reviewed compatibility decision. +[Decision Log] +- 목적과 의도: Give OpenCode Go the stable per-conversation header it requires for prompt-cache routing without exposing raw Codex identifiers. +- 기존 구현 및 제약 조건: Codex already supplies task and subagent identity, but Go requests reached every adapter without `x-opencode-session`; one static provider header would collapse unrelated conversations. +- 검토한 주요 대안: Forward a raw thread header; reuse `prompt_cache_key`; configure one global value; inject separately in Chat and Responses adapters; enrich the canonical provider before wire selection. +- 선택한 방식: Hash the existing parent-qualified session lane with a provider-specific domain, attach it as runtime-only provider metadata before wire selection, and preserve an explicit operator override. +- 다른 대안 대신 이 방식을 선택한 이유: The lane already separates sibling subagents, while cache keys may represent shared cohorts and adapter-local changes would drift across Go's mixed wire matrix. +- 장점, 단점 및 영향: Go requests gain stable opaque affinity across normal retries and key rotation without persisted config changes; requests with no stable lane remain headerless rather than receiving a per-request value that defeats affinity. + ### Passthrough SSE stream shapes (#314) Native passthrough SSE has TWO shapes, selected per request in diff --git a/tests/key-failover.test.ts b/tests/key-failover.test.ts index ea10f8ee67..b95d0981f4 100644 --- a/tests/key-failover.test.ts +++ b/tests/key-failover.test.ts @@ -10,6 +10,7 @@ import { rotateKeyOn429, rotateProviderTransportOn429, } from "../src/providers/key-failover"; +import { resolveOpenCodeGoTransport } from "../src/providers/opencode-go-transport"; import { deriveXaiConvId } from "../src/providers/xai-transport"; import { routeModel } from "../src/router"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -133,6 +134,36 @@ describe("rotateKeyOn429", () => { }); describe("rotateProviderTransportOn429", () => { + test("preserves OpenCode Go session affinity across key rotation", () => { + const config = makeConfig({ + authMode: "key", + apiKey: "key-alpha-000111222333", + apiKeyPool: pool3(), + }); + config.defaultProvider = "opencode-go"; + config.providers["opencode-go"] = { + ...config.providers.p, + baseUrl: "https://opencode.ai/zen/go/v1", + }; + delete config.providers.p; + + const initial = resolveOpenCodeGoTransport( + config.providers["opencode-go"], + "hashed-parent\0hashed-child", + ); + const initialSession = initial.headers?.["x-opencode-session"]; + expect(initialSession).toMatch(/^ocx_[0-9a-f]{32}$/); + + const rotated = rotateProviderTransportOn429(config, "opencode-go", initial, { + now: 1_000_000, + attemptedKey: "key-alpha-000111222333", + }); + + expect(rotated?.apiKey).toBe("key-beta-444555666777"); + expect(rotated?.headers?.["x-opencode-session"]).toBe(initialSession); + expect(config.providers["opencode-go"].headers?.["x-opencode-session"]).toBeUndefined(); + }); + test("keeps Kimi prompt-cache forwarding after rotating a stale pre-upgrade config", () => { const promptCacheKey = "stable-kimi-conversation-429"; const config = makeConfig({ diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts index e8f7dc491b..08bb3a74fa 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -18,6 +18,7 @@ function webSearchTool(): Record { return { type: "web_search", search_content_types: ["text", "image"], + indexed_web_access: true, search_context_size: "medium", }; } @@ -36,22 +37,22 @@ function build(modelId: string, rawBody: Record): Record) => body.tools as Array>; /** - * Muse Spark's Responses gateway 400s a plain `web_search` carrying - * `search_content_types`, while accepting the same field on `web_search_preview` and - * accepting a bare `web_search` (#2617). + * Muse Spark's Responses gateway 400s a plain `web_search` carrying provider-rejected + * fields, while accepting the preview shape and a bare `web_search` (#2617, #3378). * * The field is not ours: Codex emits it from `web_search_tool_type: TextAndImage`. This is * the same incompatibility class Codex itself handles for Bedrock by selecting text-only * search, so dropping exactly the refused field at the adapter boundary is a compatibility * guard rather than a symptom patch — the tool type and every other accepted option survive. */ -describe("#2617 Muse Spark web_search compatibility", () => { - test("drops search_content_types from a plain web_search, keeping the tool and its other fields", () => { +describe("#2617/#3378 Muse Spark web_search compatibility", () => { + test("drops rejected fields from a plain web_search, keeping the tool and its other fields", () => { const body = build("muse-spark-1.2-contributor", { tools: [webSearchTool()] }); const tool = toolsOf(body)[0]!; expect(tool.type).toBe("web_search"); expect(tool.search_context_size).toBe("medium"); expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); }); test("web_search_preview keeps the field, because the gateway accepts it there", () => { @@ -61,11 +62,13 @@ describe("#2617 Muse Spark web_search compatibility", () => { const tool = toolsOf(body)[0]!; expect(tool.type).toBe("web_search_preview"); expect(tool.search_content_types).toEqual(["text", "image"]); + expect(tool.indexed_web_access).toBe(true); }); test("another model on the same provider is untouched", () => { const body = build("gpt-5.6-luna", { tools: [webSearchTool()] }); expect(toolsOf(body)[0]!.search_content_types).toEqual(["text", "image"]); + expect(toolsOf(body)[0]!.indexed_web_access).toBe(true); }); test("a nested additional_tools declaration is sanitized too", () => { @@ -76,6 +79,7 @@ describe("#2617 Muse Spark web_search compatibility", () => { const nested = (item.tools as Array>)[0]!; expect(nested.type).toBe("web_search"); expect(Object.hasOwn(nested, "search_content_types")).toBe(false); + expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); }); test("the registry routes only the named exact models to Responses", () => { @@ -98,6 +102,7 @@ describe("#2617 Muse Spark web_search compatibility", () => { expect(tool.type).toBe("web_search"); expect(tool.search_context_size).toBe("medium"); expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); }); test("1.3 keeps the field on web_search_preview, where the gateway accepts it", () => { @@ -107,6 +112,7 @@ describe("#2617 Muse Spark web_search compatibility", () => { const tool = toolsOf(body)[0]!; expect(tool.type).toBe("web_search_preview"); expect(tool.search_content_types).toEqual(["text", "image"]); + expect(tool.indexed_web_access).toBe(true); }); test("a nested additional_tools declaration is sanitized for 1.3 too", () => { @@ -117,5 +123,6 @@ describe("#2617 Muse Spark web_search compatibility", () => { const nested = (item.tools as Array>)[0]!; expect(nested.type).toBe("web_search"); expect(Object.hasOwn(nested, "search_content_types")).toBe(false); + expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); }); }); diff --git a/tests/opencode-go-session-header.test.ts b/tests/opencode-go-session-header.test.ts new file mode 100644 index 0000000000..2a4de51b6c --- /dev/null +++ b/tests/opencode-go-session-header.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../src/providers/derive"; +import { resolveOpenCodeGoTransport } from "../src/providers/opencode-go-transport"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const MUSE_MODEL = "muse-spark-1.3-contributor"; +const CHAT_MODEL = "glm-5.2"; +const SESSION_HEADER = "x-opencode-session"; + +function opencodeGo(overrides: Partial = {}): OcxProviderConfig { + const entry = getProviderRegistryEntry("opencode-go"); + if (!entry) throw new Error("missing opencode-go registry fixture"); + return { ...providerConfigSeed(entry), apiKey: "test-key", ...overrides }; +} + +function codexHeaders(child = "child-thread-a"): Record { + return { + "content-type": "application/json", + "x-codex-parent-thread-id": "raw-parent-thread", + "thread-id": child, + session_id: "raw-session-id", + }; +} + +function upstreamResponse(url: string): Response { + if (url.endsWith("/responses")) { + return Response.json({ + id: "resp_opencode_go_session", + object: "response", + status: "completed", + output: [], + usage: { + input_tokens: 1, + output_tokens: 0, + total_tokens: 1, + input_tokens_details: { cached_tokens: 0 }, + }, + }); + } + return Response.json({ + id: "chatcmpl_opencode_go_session", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); +} + +async function captureRequest(input: { + providerName?: string; + model?: string; + child?: string; + provider?: OcxProviderConfig; +} = {}): Promise<{ url: string; headers: Headers }> { + const providerName = input.providerName ?? "opencode-go"; + const model = input.model ?? MUSE_MODEL; + const requests: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => { + const url = String(requestInput); + requests.push({ url, headers: new Headers(init?.headers) }); + return upstreamResponse(url); + }) as typeof fetch; + + const config = { + providers: { [providerName]: input.provider ?? opencodeGo() }, + } as unknown as OcxConfig; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: codexHeaders(input.child), + body: JSON.stringify({ model: `${providerName}/${model}`, input: "ping", stream: false }), + }), + config, + { model: "", provider: "" }, + { inboundWire: "responses" }, + ); + + expect(response.status).toBe(200); + expect(requests).toHaveLength(1); + return requests[0]!; +} + +describe("OpenCode Go session affinity (#3344)", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("sends one stable opaque session header on Responses and Chat wires", async () => { + const responses = await captureRequest({ model: MUSE_MODEL }); + const chat = await captureRequest({ model: CHAT_MODEL }); + const responsesSession = responses.headers.get(SESSION_HEADER); + const chatSession = chat.headers.get(SESSION_HEADER); + + expect(responses.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(chat.url).toBe("https://opencode.ai/zen/go/v1/chat/completions"); + expect(responsesSession).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(chatSession).toBe(responsesSession); + expect([...responses.headers.keys()].filter(name => name === SESSION_HEADER)).toHaveLength(1); + }); + + test("separates sibling subagents without exposing raw Codex identities", async () => { + const first = await captureRequest({ child: "child-thread-a" }); + const second = await captureRequest({ child: "child-thread-b" }); + const firstSession = first.headers.get(SESSION_HEADER); + const secondSession = second.headers.get(SESSION_HEADER); + + expect(firstSession).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(secondSession).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(secondSession).not.toBe(firstSession); + expect(firstSession).not.toContain("raw-parent-thread"); + expect(firstSession).not.toContain("child-thread-a"); + expect(firstSession).not.toContain("raw-session-id"); + }); + + test("recognizes a renamed provider by its canonical OpenCode Go destination", async () => { + const captured = await captureRequest({ providerName: "opencode-go-2" }); + expect(captured.headers.get(SESSION_HEADER)).toMatch(/^ocx_[0-9a-f]{32}$/); + }); + + test("preserves an explicit operator session header case-insensitively", async () => { + const captured = await captureRequest({ + provider: opencodeGo({ headers: { "X-OpenCode-Session": "operator-session" } }), + }); + expect(captured.headers.get(SESSION_HEADER)).toBe("operator-session"); + expect([...captured.headers.keys()].filter(name => name === SESSION_HEADER)).toHaveLength(1); + }); + + test("keeps generated affinity runtime-only and omits it without a stable lane", async () => { + const configured = opencodeGo(); + await captureRequest({ provider: configured }); + expect(configured.headers?.[SESSION_HEADER]).toBeUndefined(); + expect(resolveOpenCodeGoTransport(configured, undefined)).toBe(configured); + expect(resolveOpenCodeGoTransport(configured, undefined).headers?.[SESSION_HEADER]).toBeUndefined(); + }); + + test("does not inject the header into a lookalike destination", async () => { + const captured = await captureRequest({ + providerName: "custom-go", + provider: opencodeGo({ baseUrl: "https://opencode.ai.evil.test/zen/go/v1" }), + }); + expect(captured.headers.has(SESSION_HEADER)).toBe(false); + }); +});