Compare commits

..

4 Commits

14 changed files with 372 additions and 194 deletions

View File

@@ -1 +0,0 @@
- fix(providers): surface the real transport diagnosis (DNS/socket cause) instead of a bare "fetch failed" in provider validation errors (#14309)

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

@@ -512,6 +512,8 @@ export async function handleChatCore({
defaultThinkingEffort,
});
let { provider, model, extendedContext } = modelInfo;
const getExecutorClientHeaders = () =>
buildExecutorClientHeaders(clientRawRequest?.headers, userAgent, { provider, body });
// Keep the selected rule across format conversion, retries and refreshed credentials.
// Each combo leg gets its own execution context; nothing is written to shared accounts.
const reasoningRuleDirective = body?._omnirouteReasoningRule;
@@ -3198,10 +3200,7 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(
clientRawRequest?.headers,
userAgent
),
clientHeaders: getExecutorClientHeaders(),
clientResponseFormat,
onCredentialsRefreshed,
skipUpstreamRetry,
@@ -3385,10 +3384,7 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(
clientRawRequest?.headers,
userAgent
),
clientHeaders: getExecutorClientHeaders(),
clientResponseFormat,
onCredentialsRefreshed,
skipUpstreamRetry,
@@ -4499,7 +4495,7 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
clientHeaders: getExecutorClientHeaders(),
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

@@ -15,7 +15,6 @@ import {
} from "./proxyDispatcher.ts";
import tlsClient, { type TlsFetchOptions, guardTlsFirstByte } from "./tlsClient.ts";
import { withUpstreamStatusCapture } from "./upstreamStatusCapture.ts";
import { describeFallbackFailure, redactProxyDetailsInMessage } from "./proxyFetchRedaction.ts";
import { isProxyReachable } from "@/lib/proxyHealth";
import {
isControlPlaneProxyDirectFallbackEnabled,
@@ -341,6 +340,20 @@ function isWreqProxySupported(proxyUrl: string): boolean {
}
}
/**
* Redact proxy URLs (and any bare `user:pass@host` credential tokens) from an
* upstream transport-error message before it is surfaced. #10032 keeps the
* underlying failure reason in the propagated error for diagnosability, but
* the raw message can embed the full proxy URL — including userinfo
* credentials — which must never bubble into response bodies (#9837, Hard
* Rule #12).
*/
function redactProxyDetailsInMessage(message: string): string {
return message
.replace(/\b(?:https?|socks[45][ah]?|socks):\/\/\S+/gi, "[redacted-proxy]")
.replace(/\b[^\s:@/]+:[^\s@/]*@\S+/g, "[redacted-proxy]");
}
function sanitizeTransportError(
error: unknown,
message: string,
@@ -895,10 +908,7 @@ async function patchedFetchUnrecorded(
continue;
}
if (hasNonReplayableBody) {
const detail = describeFallbackFailure(
describeFetchCause(dispatcherError),
"skipped: non-replayable request body"
);
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[skipped: non-replayable request body]`;
console.warn(
`[ProxyFetch] skipping native fetch fallback for non-replayable body: ${detail}`
);
@@ -942,10 +952,7 @@ async function patchedFetchUnrecorded(
return await _nativeFallback(input, options);
} catch (nativeError) {
// Surface both dispatcher and native causes immediately.
const detail = describeFallbackFailure(
describeFetchCause(dispatcherError),
describeFetchCause(nativeError)
);
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`;
console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`);
if (nativeError instanceof Error) {
(nativeError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail;

View File

@@ -1,27 +0,0 @@
// Extracted from proxyFetch.ts (frozen file-size baseline — #14309) so the
// transport-error diagnostics built there can be redacted without growing
// the frozen file.
//
// #10032 keeps the underlying transport failure reason in the propagated
// error for diagnosability, but the raw message can embed a full proxy URL
// — including userinfo credentials — which must never bubble into response
// bodies (#9837, Hard Rule #12).
/**
* Redact proxy URLs (and any bare `user:pass@host` credential tokens) from an
* upstream transport-error message before it is surfaced.
*/
export function redactProxyDetailsInMessage(message: string): string {
return message
.replace(/\b(?:https?|socks[45][ah]?|socks):\/\/\S+/gi, "[redacted-proxy]")
.replace(/\b[^\s:@/]+:[^\s@/]*@\S+/g, "[redacted-proxy]");
}
/**
* Builds the `.proxyFetchDetail` diagnosis for proxyFetch.ts's direct-path
* (pooled undici dispatcher + native fetch fallback) branches, redacted the
* same way as the proxy-path message (see redactProxyDetailsInMessage above).
*/
export function describeFallbackFailure(dispatcherCause: string, nativeDetail: string): string {
return redactProxyDetailsInMessage(`dispatcher=[${dispatcherCause}] native=[${nativeDetail}]`);
}

View File

@@ -178,26 +178,6 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow
return toValidationErrorResult(error);
}
/**
* proxyFetch.ts computes a detailed transport diagnosis (DNS/socket error
* code, syscall, address) whenever a direct fetch fails on both the pooled
* undici dispatcher and the native-fetch fallback, and attaches it to the
* thrown error as `.proxyFetchDetail`. safeOutboundFetch's
* normalizeFetchFailure() then wraps that error in a SafeOutboundFetchError
* whose `.message` is copied from the generic "fetch failed" string and
* whose `.cause` is the original error carrying `.proxyFetchDetail`. Without
* this, the computed diagnosis never reaches the caller (#14309).
*/
function extractProxyFetchDetail(error: unknown): string | undefined {
if (!(error instanceof Error)) return undefined;
const cause = (error as Error & { cause?: unknown }).cause;
if (!(cause instanceof Error)) return undefined;
const detail = (cause as Error & { proxyFetchDetail?: unknown }).proxyFetchDetail;
return typeof detail === "string" && detail.length > 0 ? detail : undefined;
}
const GENERIC_TRANSPORT_FAILURE_PATTERN = /^fetch failed$/i;
export function toValidationErrorResult(error: unknown) {
let rawMessage: unknown = error || "Validation failed";
try {
@@ -205,17 +185,6 @@ export function toValidationErrorResult(error: unknown) {
} catch {
rawMessage = "Validation failed";
}
try {
if (
typeof rawMessage === "string" &&
GENERIC_TRANSPORT_FAILURE_PATTERN.test(rawMessage.trim())
) {
const detail = extractProxyFetchDetail(error);
if (detail) rawMessage = `Network error: ${detail}`;
}
} catch {
// Diagnostic enrichment is advisory; never let it break error reporting.
}
const message = sanitizeErrorMessage(rawMessage);
let statusCode: number | null = null;
let timeout = false;

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"
);
});

View File

@@ -1,61 +0,0 @@
// Repro for #14309 — "all provider validation fails with 'fetch failed'".
//
// open-sse/utils/proxyFetch.ts already computes a rich diagnostic string
// (dispatcher cause + native-fallback cause, including the real DNS/socket
// error code) whenever BOTH the pooled undici dispatcher path AND the
// native-fetch fallback fail, and attaches it to the thrown error as
// `.proxyFetchDetail` (open-sse/utils/proxyFetch.ts:953-961; proven attached
// by the existing tests/unit/proxyfetch-undici-retry.test.ts).
//
// That thrown error then reaches safeOutboundFetch()'s catch block
// (src/shared/network/safeOutboundFetch.ts::normalizeFetchFailure), which
// wraps it into a `SafeOutboundFetchError` whose `.message` is copied from
// the ORIGINAL error's generic "fetch failed" message and whose `.cause` is
// the original error (carrying `.proxyFetchDetail`).
//
// `toValidationErrorResult()` in src/lib/providers/validation/transport.ts
// — the function that turns that thrown error into the JSON body
// `/api/providers/validate` sends to the dashboard — only ever reads
// `error.message`. It never looks at `error.cause`, so the diagnostic detail
// that was carefully computed two layers down is silently discarded before
// it ever reaches the user, and the dashboard always shows the bare,
// non-actionable "fetch failed" string regardless of the real underlying
// cause (DNS failure, connection refused, TLS error, etc.) — exactly what
// #14309 reports.
import { test } from "node:test";
import assert from "node:assert/strict";
import { toValidationErrorResult } from "../../src/lib/providers/validation/transport";
import { SafeOutboundFetchError } from "../../src/shared/network/safeOutboundFetch";
test("toValidationErrorResult should surface the computed proxyFetchDetail diagnosis (via error.cause) instead of the generic 'fetch failed' message (#14309)", () => {
// Mirrors exactly what proxyFetch.ts's native-fallback-also-failed branch
// attaches to the original error (open-sse/utils/proxyFetch.ts:955-958).
const nativeError = new Error("fetch failed") as Error & { proxyFetchDetail?: string };
nativeError.proxyFetchDetail =
"dispatcher=[fetch failed code=UND_ERR_SOCKET] native=[getaddrinfo ENOTFOUND api.mistral.ai code=ENOTFOUND syscall=getaddrinfo]";
// Mirrors exactly what safeOutboundFetch.ts's normalizeFetchFailure() produces
// for a generic (non-SafeOutboundFetchError, non-FetchTimeoutError) transport
// failure: message copied from the original error, cause = the original error.
const wrapped = new SafeOutboundFetchError(nativeError.message, {
code: "NETWORK_ERROR",
url: "https://api.mistral.ai/v1/models",
method: "GET",
attempts: 1,
isRetryable: true,
cause: nativeError,
});
const result = toValidationErrorResult(wrapped);
assert.notEqual(
result.error,
"fetch failed",
"expected behavior: a concrete transport diagnosis was computed two layers down (error.cause.proxyFetchDetail), so the response must not collapse to the bare, non-actionable 'fetch failed' string"
);
assert.match(
result.error || "",
/ENOTFOUND|UND_ERR_SOCKET/,
"expected behavior: the underlying DNS/socket error code should reach the dashboard so the operator can actually diagnose the failure"
);
});