Compare commits

...

3 Commits

9 changed files with 358 additions and 59 deletions

View File

@@ -0,0 +1 @@
- fix(opencode): preserve explicit native and Claude conversation IDs before request translation, keeping the existing canonical session shape and fingerprint fallback. ([#14390](https://github.com/diegosouzapw/OmniRoute/pull/14390))

View File

@@ -0,0 +1,29 @@
---
title: "OpenCode conversation identity"
version: 3.8.51
lastUpdated: 2026-09-21
---
# OpenCode conversation identity
OpenCode and OpenCode Go reuse explicit conversation IDs across turns. The
resolver checks `x-opencode-session`, existing affinity/session headers, native
CLI session/thread headers, request metadata, and then top-level session/thread
fields. Claude's JSON-encoded `metadata.user_id` is inspected for a session ID;
an opaque account/user ID alone is never treated as a conversation ID.
Values containing control characters, empty IDs and IDs longer than 256
characters are ignored. JSON metadata parsing is bounded. Before translation,
the executor header normalizer preserves this identity only for the OpenCode
providers; it does not inject OpenCode headers for other providers.
The existing canonical `ses_` encoding and conversation fingerprint fallback
remain unchanged. With no explicit ID, the first user message and other
existing fingerprint inputs continue to determine continuity. This is not a
new authorization boundary or proof of upstream content isolation.
The implementation lives in `open-sse/utils/opencodeSessionIdentity.ts`,
`open-sse/utils/opencodeHeaders.ts` and
`open-sse/handlers/chatCore/executorClientHeaders.ts`. Regression tests cover
native IDs, Claude metadata, precedence, invalid values and the existing
fingerprint behavior without paid upstream calls.

View File

@@ -20,6 +20,7 @@ import {
forwardOpencodeClientHeaders,
resolveOpencodeCliDefaults,
} from "../utils/opencodeHeaders.ts";
import { projectOpencodeSessionBody } from "../utils/opencodeSessionIdentity.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
@@ -29,7 +30,12 @@ import {
isEmptyUpstreamRejection,
extractChatcmplId,
} from "./accountRotation.ts";
import { markCooldown, markOutcome, markSuccess, noteResponseServed } from "./opencodeAccountHealth.ts";
import {
markCooldown,
markOutcome,
markSuccess,
noteResponseServed,
} from "./opencodeAccountHealth.ts";
import {
isOpencodeFreeTierRefusal,
isOpencodeGeoBlocked,
@@ -1065,30 +1071,12 @@ export class OpencodeExecutor extends BaseExecutor {
gatedScope
);
this._clientSession = clientSuppliedOpencodeSession(clientHeaders);
this._clientSession = clientSuppliedOpencodeSession(clientHeaders, body);
if (clientHeaders || cliDefaults) {
const b = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, {
synthesizeRequestId: true,
cliDefaults,
sessionBody: b
? {
model: typeof b.model === "string" ? b.model : undefined,
system: b.system,
messages: Array.isArray(b.messages)
? (b.messages as Array<{ role?: string; content?: unknown }>)
: undefined,
// The Responses surface carries the conversation under `input`; without it the
// fingerprint collapses to the model alone and every conversation on that model
// would share one upstream session.
input: Array.isArray(b.input)
? (b.input as Array<{ role?: string; content?: unknown }>)
: undefined,
tools: Array.isArray(b.tools)
? (b.tools as Array<{ name?: string; function?: { name?: string } }>)
: undefined,
}
: undefined,
sessionBody: projectOpencodeSessionBody(body),
});
}

View File

@@ -3200,7 +3200,8 @@ export async function handleChatCore({
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(
clientRawRequest?.headers,
userAgent
userAgent,
{ provider, body }
),
clientResponseFormat,
onCredentialsRefreshed,
@@ -3387,7 +3388,8 @@ export async function handleChatCore({
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(
clientRawRequest?.headers,
userAgent
userAgent,
{ provider, body }
),
clientResponseFormat,
onCredentialsRefreshed,
@@ -4499,7 +4501,9 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent, {
provider, body,
}),
clientResponseFormat,
onCredentialsRefreshed,
skipUpstreamRetry: isCombo,

View File

@@ -5,12 +5,14 @@
* Pure helper extracted from chatCore: normalizes a Headers instance or a plain header object into a
* lowercased-tolerant Record<string,string>, and backfills the client User-Agent (both casings) when
* one is supplied and not already present. Returns null when nothing was collected. Side-effect-free;
* behaviour is byte-identical to the previous module-level function.
* OpenCode additionally retains explicit conversation identity before body translation.
*/
import { preserveOpencodeSessionIdentity } from "../../utils/opencodeSessionIdentity.ts";
export function buildExecutorClientHeaders(
headers: Headers | Record<string, unknown> | null | undefined,
userAgent?: string | null
userAgent?: string | null,
request?: { provider?: string; body?: unknown }
) {
const normalized: Record<string, string> = {};
const isLeaseControlHeader = (key: string) => {
@@ -38,5 +40,6 @@ export function buildExecutorClientHeaders(
normalized["User-Agent"] = normalizedUserAgent;
}
preserveOpencodeSessionIdentity(normalized, request);
return Object.keys(normalized).length > 0 ? normalized : null;
}

View File

@@ -1,6 +1,10 @@
import { createHash, randomBytes, randomUUID } from "crypto";
import { setUserAgentHeader } from "../executors/base.ts";
import { generateSessionId } from "../services/sessionManager.ts";
import {
resolveOpencodeSessionIdentity,
type OpencodeSessionBody,
} from "./opencodeSessionIdentity.ts";
/**
* Default synthesized User-Agent. The upstream only parses the version, so this literal
@@ -35,13 +39,10 @@ export function satisfiesOpencodeUserAgentContract(userAgent: string | null | un
* follows it differ — including in their tool list, which is the very thing being joined.
*/
export function clientSuppliedOpencodeSession(
clientHeaders: Record<string, string> | null | undefined
clientHeaders: Record<string, string> | null | undefined,
body?: unknown
): string | undefined {
if (!clientHeaders) return undefined;
const value =
findHeader(clientHeaders, "x-opencode-session") ?? findHeader(clientHeaders, "x-session-id");
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
return resolveOpencodeSessionIdentity(clientHeaders, body);
}
/**
@@ -155,13 +156,7 @@ export function forwardOpencodeClientHeaders(
options?: {
synthesizeRequestId?: boolean;
cliDefaults?: { userAgent: string; client: string; project: string };
sessionBody?: {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
};
sessionBody?: OpencodeSessionBody;
}
): void {
// 1. Forward User-Agent
@@ -187,19 +182,8 @@ export function forwardOpencodeClientHeaders(
}
// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
if (options?.synthesizeRequestId && !headers["x-opencode-session"]) {
const sessionAffinity =
findHeader(clientHeaders, "x-session-affinity") || findHeader(clientHeaders, "x-session-id");
if (sessionAffinity) {
// Kept as-is here. When identity synthesis is on, applyCliDefaults renders it in the
// canonical shape below; with the synthesis opted out this path stays byte-identical
// to before, since opting out means no fabricated identity at all.
headers["x-opencode-session"] = sessionAffinity;
if (!headers["x-opencode-request"]) {
headers["x-opencode-request"] = randomUUID();
}
}
if (options?.synthesizeRequestId || options?.cliDefaults) {
applySessionFallback(headers, clientHeaders, options.sessionBody);
}
// 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects
@@ -209,6 +193,21 @@ export function forwardOpencodeClientHeaders(
}
}
/** Fill missing session/request identity without changing the CLI synthesis policy. */
function applySessionFallback(
headers: Record<string, string>,
clientHeaders: Record<string, string>,
sessionBody?: OpencodeSessionBody
): void {
if (headers["x-opencode-session"]) return;
const sessionAffinity = resolveOpencodeSessionIdentity(clientHeaders, sessionBody);
if (!sessionAffinity) return;
// Keep the caller's identity as-is when CLI synthesis is disabled; applyCliDefaults
// renders it in the canonical shape only when that policy is enabled.
headers["x-opencode-session"] = sessionAffinity;
headers["x-opencode-request"] ||= randomUUID();
}
/**
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For
* x-opencode-* headers, client values always win (defaults only fill gaps). The
@@ -221,13 +220,7 @@ export function forwardOpencodeClientHeaders(
function applyCliDefaults(
headers: Record<string, string>,
cliDefaults: { userAgent: string; client: string; project: string },
sessionBody?: {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
}
sessionBody?: OpencodeSessionBody
): void {
// A client User-Agent is kept only when it already satisfies the upstream contract.
// The previous rule kept anything starting with `opencode-cli/`, which carries no

View File

@@ -0,0 +1,105 @@
/** Explicit conversation identity, before the existing fingerprint fallback. */
const HEADER_NAMES = [
"x-opencode-session",
"x-session-affinity",
"x-session-id",
"x-claude-code-session-id",
"session_id",
"session-id",
"x-session_id",
"thread_id",
"thread-id",
"x-thread-id",
] as const;
const BODY_NAMES = [
"session_id",
"sessionId",
"thread_id",
"threadId",
"conversation_id",
"conversationId",
];
function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function identity(value: unknown): string | undefined {
if (typeof value !== "string" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))
return undefined;
return value.trim() || undefined;
}
function bodyIdentity(value: unknown): string | undefined {
const input = record(value);
if (!input) return undefined;
for (const key of BODY_NAMES) {
const id = identity(input[key]);
if (id) return id;
}
return undefined;
}
function claudeIdentity(value: unknown): string | undefined {
if (typeof value !== "string" || value.length > 4096) return undefined;
try {
return bodyIdentity(JSON.parse(value));
} catch {
return undefined;
}
}
/** Headers win over metadata; an opaque user/account ID is never a session ID. */
export function resolveOpencodeSessionIdentity(
headers: Record<string, string> | null | undefined,
body?: unknown
): string | undefined {
const normalized = new Map(
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value])
);
for (const name of HEADER_NAMES) {
const id = identity(normalized.get(name));
if (id) return id;
}
const input = record(body);
const metadata = record(input?.metadata);
return bodyIdentity(metadata) || claudeIdentity(metadata?.user_id) || bodyIdentity(input);
}
export function preserveOpencodeSessionIdentity(
headers: Record<string, string>,
request?: { provider?: string; body?: unknown }
): void {
if (request?.provider !== "opencode" && request?.provider !== "opencode-go") return;
const sessionId = resolveOpencodeSessionIdentity(headers, request.body);
if (sessionId) headers["x-opencode-session"] = sessionId;
}
export interface OpencodeSessionBody {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
metadata?: unknown;
session_id?: unknown;
thread_id?: unknown;
}
/** Keep only fingerprint/identity inputs; never add this projection to an upstream body. */
export function projectOpencodeSessionBody(body: unknown): OpencodeSessionBody | undefined {
const input = record(body);
if (!input) return undefined;
return {
model: typeof input.model === "string" ? input.model : undefined,
system: input.system,
messages: Array.isArray(input.messages) ? input.messages : undefined,
input: Array.isArray(input.input) ? input.input : undefined,
tools: Array.isArray(input.tools) ? input.tools : undefined,
metadata: input.metadata,
session_id: input.session_id,
thread_id: input.thread_id,
};
}

View File

@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clientSuppliedOpencodeSession,
forwardOpencodeClientHeaders,
} from "../../open-sse/utils/opencodeHeaders.ts";
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
const defaults = { userAgent: "opencode/1.18.31", client: "desktop", project: "global" };
const body = { model: "big-pickle", messages: [{ role: "user", content: "same prompt" }] };
function session(headers: Record<string, string>) {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(output, headers, {
synthesizeRequestId: true,
cliDefaults: defaults,
sessionBody: body,
});
return output["x-opencode-session"];
}
for (const header of ["session_id", "thread_id", "x-claude-code-session-id"]) {
test(`${header} separates conversations with identical prompts`, () => {
assert.notEqual(
session({ [header]: "conversation-a" }),
session({ [header]: "conversation-b" })
);
assert.equal(session({ [header]: "conversation-a" }), session({ [header]: "conversation-a" }));
assert.equal(clientSuppliedOpencodeSession({ [header]: "conversation-a" }), "conversation-a");
});
}
test("explicit OpenCode session wins over native aliases", () => {
assert.equal(
session({ "x-opencode-session": "explicit", thread_id: "other" }),
session({ "x-opencode-session": "explicit" })
);
});
test("executor carries Claude metadata identity to upstream and tool cache", () => {
const executor = new OpencodeExecutor("opencode-go");
const build = (id: string) =>
executor.buildHeaders(
null,
true,
null,
body.model,
{},
{
...body,
metadata: { user_id: JSON.stringify({ session_id: id }) },
}
);
assert.notEqual(
build("conversation-a")["x-opencode-session"],
build("conversation-b")["x-opencode-session"]
);
assert.equal(executor._clientSession, "conversation-b");
});
test("native aliases never add OpenCode headers to a generic forwarding call", () => {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(output, { thread_id: "conversation-a" });
assert.equal(output["x-opencode-session"], undefined);
});
test("untrusted native IDs reject controls and excessive length", () => {
for (const id of ["bad\nheader", "bad\u0000header", "x".repeat(257)]) {
assert.equal(clientSuppliedOpencodeSession({ thread_id: id }), undefined);
}
});
test("native fallback preserves raw identity and an existing request when CLI synthesis is off", () => {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(
output,
{ thread_id: "native-conversation", "x-opencode-request": "existing-request" },
{ synthesizeRequestId: true }
);
assert.equal(output["x-opencode-session"], "native-conversation");
assert.equal(output["x-opencode-request"], "existing-request");
});
test("an existing outbound session bypasses fallback request synthesis", () => {
const output = { "x-opencode-session": "existing-session" } as Record<string, string>;
forwardOpencodeClientHeaders(
output,
{ thread_id: "native-conversation" },
{
synthesizeRequestId: true,
}
);
assert.deepEqual(output, { "x-opencode-session": "existing-session" });
});
test("invalid body identity does not synthesize a request without CLI defaults", () => {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(
output,
{},
{
synthesizeRequestId: true,
sessionBody: { metadata: { session_id: "bad\nidentity" } },
}
);
assert.deepEqual(output, {});
});

View File

@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExecutorClientHeaders } from "../../open-sse/handlers/chatCore/executorClientHeaders.ts";
import { resolveOpencodeSessionIdentity } from "../../open-sse/utils/opencodeSessionIdentity.ts";
test("original Claude metadata survives executor header normalization before translation", () => {
const body = { metadata: { user_id: JSON.stringify({ session_id: "claude-conversation" }) } };
const result = buildExecutorClientHeaders({}, "claude-cli", { provider: "opencode-go", body });
assert.equal(result?.["x-opencode-session"], "claude-conversation");
});
test("body identity is not forwarded for another provider", () => {
const result = buildExecutorClientHeaders({}, undefined, {
provider: "openai",
body: { thread_id: "private" },
});
assert.equal(result, null);
});
test("OpenCode headers preserve native identity and strip lease-control headers", () => {
const result = buildExecutorClientHeaders(
new Headers({ Session_Id: "native-conversation", "x-omniroute-lease-owner": "private-owner" }),
undefined,
{ provider: "opencode" }
);
assert.equal(result?.["x-opencode-session"], "native-conversation");
assert.equal(result?.["x-omniroute-lease-owner"], undefined);
});
test("malformed or account-only Claude metadata never becomes conversation identity", () => {
for (const user_id of ["bad-json", JSON.stringify({ account_id: "user" }), "x".repeat(4097)]) {
const result = buildExecutorClientHeaders({}, undefined, {
provider: "opencode-go",
body: { metadata: { user_id } },
});
assert.equal(result, null);
}
});
test("identity precedence is explicit headers, native headers, metadata, then body", () => {
const body = { metadata: { session_id: "metadata" }, thread_id: "body" };
assert.equal(
resolveOpencodeSessionIdentity({ "X-OPENCODE-SESSION": "explicit", thread_id: "native" }, body),
"explicit"
);
assert.equal(resolveOpencodeSessionIdentity({ thread_id: "native" }, body), "native");
assert.equal(resolveOpencodeSessionIdentity({}, body), "metadata");
assert.equal(resolveOpencodeSessionIdentity(null, { thread_id: "body" }), "body");
});
test("invalid identities are ignored without inventing an account-scoped session", () => {
for (const invalid of [null, false, 42, [], {}, "", "\n", "bad\u007fvalue", "x".repeat(257)]) {
assert.equal(
resolveOpencodeSessionIdentity({}, { metadata: { session_id: invalid } }),
undefined
);
}
assert.equal(
resolveOpencodeSessionIdentity({}, { metadata: { user_id: "account-name" } }),
undefined
);
assert.equal(
resolveOpencodeSessionIdentity(
{},
{ metadata: { user_id: JSON.stringify({ session_id: "valid" }) } }
),
"valid"
);
});