Compare commits

..

2 Commits

Author SHA1 Message Date
diegosouzapw
5427c987df docs(changelog): record scoped-override lockfile repair (#14384) 2026-09-21 19:15:49 -03:00
diegosouzapw
b5c6f7bb59 fix(deps): reconcile overridden transitive lockfile entries 2026-09-21 18:30:41 -03:00
11 changed files with 56 additions and 313 deletions

View File

@@ -0,0 +1 @@
- **fix(deps):** Reconcile the nested undici and brace-expansion lockfile entries with npm's scoped-override resolution while preserving dependency declarations and security overrides ([#14384](https://github.com/diegosouzapw/OmniRoute/pull/14384)).

View File

@@ -1 +0,0 @@
- 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

@@ -1,29 +0,0 @@
---
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,7 +20,6 @@ import {
forwardOpencodeClientHeaders,
resolveOpencodeCliDefaults,
} from "../utils/opencodeHeaders.ts";
import { projectOpencodeSessionBody } from "../utils/opencodeSessionIdentity.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
@@ -30,12 +29,7 @@ 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,
@@ -1071,12 +1065,30 @@ export class OpencodeExecutor extends BaseExecutor {
gatedScope
);
this._clientSession = clientSuppliedOpencodeSession(clientHeaders, body);
this._clientSession = clientSuppliedOpencodeSession(clientHeaders);
if (clientHeaders || cliDefaults) {
const b = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, {
synthesizeRequestId: true,
cliDefaults,
sessionBody: projectOpencodeSessionBody(body),
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,
});
}

View File

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

View File

@@ -5,14 +5,12 @@
* 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;
* OpenCode additionally retains explicit conversation identity before body translation.
* behaviour is byte-identical to the previous module-level function.
*/
import { preserveOpencodeSessionIdentity } from "../../utils/opencodeSessionIdentity.ts";
export function buildExecutorClientHeaders(
headers: Headers | Record<string, unknown> | null | undefined,
userAgent?: string | null,
request?: { provider?: string; body?: unknown }
userAgent?: string | null
) {
const normalized: Record<string, string> = {};
const isLeaseControlHeader = (key: string) => {
@@ -40,6 +38,5 @@ export function buildExecutorClientHeaders(
normalized["User-Agent"] = normalizedUserAgent;
}
preserveOpencodeSessionIdentity(normalized, request);
return Object.keys(normalized).length > 0 ? normalized : null;
}

View File

@@ -1,10 +1,6 @@
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
@@ -39,10 +35,13 @@ 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,
body?: unknown
clientHeaders: Record<string, string> | null | undefined
): string | undefined {
return resolveOpencodeSessionIdentity(clientHeaders, body);
if (!clientHeaders) return undefined;
const value =
findHeader(clientHeaders, "x-opencode-session") ?? findHeader(clientHeaders, "x-session-id");
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
/**
@@ -156,7 +155,13 @@ export function forwardOpencodeClientHeaders(
options?: {
synthesizeRequestId?: boolean;
cliDefaults?: { userAgent: string; client: string; project: string };
sessionBody?: OpencodeSessionBody;
sessionBody?: {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
};
}
): void {
// 1. Forward User-Agent
@@ -182,8 +187,9 @@ export function forwardOpencodeClientHeaders(
}
// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
if ((options?.synthesizeRequestId || options?.cliDefaults) && !headers["x-opencode-session"]) {
const sessionAffinity = resolveOpencodeSessionIdentity(clientHeaders, options.sessionBody);
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
@@ -215,7 +221,13 @@ export function forwardOpencodeClientHeaders(
function applyCliDefaults(
headers: Record<string, string>,
cliDefaults: { userAgent: string; client: string; project: string },
sessionBody?: OpencodeSessionBody
sessionBody?: {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
}
): 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

@@ -1,105 +0,0 @@
/** 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,
};
}

12
package-lock.json generated
View File

@@ -235,9 +235,9 @@
}
},
"node_modules/@ai-sdk/provider-utils/node_modules/undici": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -29589,9 +29589,9 @@
}
},
"node_modules/minimatch/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,71 +0,0 @@
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);
}
});

View File

@@ -1,69 +0,0 @@
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"
);
});