Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1966,25 +1966,27 @@ 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([
"muse-spark-1.3-contributor",
"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;
Expand All @@ -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;
});
Expand Down
41 changes: 41 additions & 0 deletions src/providers/opencode-go-transport.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<T extends OcxProviderConfig>(
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),
},
};
}
3 changes: 3 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -287,6 +288,7 @@ import {
conversationIdFromResponsesRequest,
normalizeLogConversationId,
reasoningReplayConversationIdFromResponsesRequest,
sessionLaneIdFromRequest,
sessionIdHeaderFromRequest,
} from "../request-log-conversation";
import type { AttemptRecoveryKind } from "../../usage/log";
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/key-failover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
17 changes: 12 additions & 5 deletions tests/muse-spark-web-search-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function webSearchTool(): Record<string, unknown> {
return {
type: "web_search",
search_content_types: ["text", "image"],
indexed_web_access: true,
search_context_size: "medium",
};
}
Expand All @@ -36,22 +37,22 @@ function build(modelId: string, rawBody: Record<string, unknown>): Record<string
const toolsOf = (body: Record<string, unknown>) => body.tools as Array<Record<string, unknown>>;

/**
* 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", () => {
Expand All @@ -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", () => {
Expand All @@ -76,6 +79,7 @@ describe("#2617 Muse Spark web_search compatibility", () => {
const nested = (item.tools as Array<Record<string, unknown>>)[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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -117,5 +123,6 @@ describe("#2617 Muse Spark web_search compatibility", () => {
const nested = (item.tools as Array<Record<string, unknown>>)[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);
});
});
Loading
Loading