mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-23 23:33:38 +03:00
Compare commits
4 Commits
fix/14309-
...
fix/relay-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33f306059 | ||
|
|
1a325166bf | ||
|
|
f6e30a261e | ||
|
|
88c3a880f5 |
@@ -1 +0,0 @@
|
||||
- fix(providers): surface the real transport diagnosis (DNS/socket cause) instead of a bare "fetch failed" in provider validation errors (#14309)
|
||||
1
changelog.d/fixes/14390-relay-opencode-session.md
Normal file
1
changelog.d/fixes/14390-relay-opencode-session.md
Normal 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))
|
||||
29
docs/reference/OPENCODE-SESSION-IDENTITY.md
Normal file
29
docs/reference/OPENCODE-SESSION-IDENTITY.md
Normal 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.
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
105
open-sse/utils/opencodeSessionIdentity.ts
Normal file
105
open-sse/utils/opencodeSessionIdentity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}]`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
107
tests/unit/opencode-native-session-identity.test.ts
Normal file
107
tests/unit/opencode-native-session-identity.test.ts
Normal 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, {});
|
||||
});
|
||||
69
tests/unit/opencode-session-before-translation.test.ts
Normal file
69
tests/unit/opencode-session-before-translation.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user