Compare commits

...

7 Commits

Author SHA1 Message Date
Markus Hartung
9603ec1bf1 fix(sse): log upstream error body in COMBO per-target failure warnings (#10597) 2026-08-20 20:34:38 -03:00
MSiva
bc9090ba65 fix(translator): merge consecutive same-role contents in direct claudeToGeminiRequest (#10658)
Obrigado — bug real: a tradução direta claudeToGeminiRequest emitia mensagens consecutivas do mesmo role em contents[], o que a API do Gemini rejeita com HTTP 400 (turnos alternados user/model são obrigatórios). Traz claudeToGeminiRequest à paridade com openaiToGeminiRequest reutilizando mergeConsecutiveSameRoleContents.

Validação (worktree combinado a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/claude-to-gemini-consecutive-roles.test.ts — 7/7 passando
- tests/unit/claude-to-gemini-budget-tokens-zero-6813.test.ts — 2/2 passando (sem regressão)
2026-08-20 20:04:20 -03:00
Paco Cartones
d9cb4f5f5d fix(files): validate the list limit query parameter (#10673)
Obrigado — bug real: GET /v1/files aceitava limit negativo sem validação (`-5 || 20` avalia truthy em -5, então Math.min(-5, 10000) = -5 passava direto). Agora valida integer/positivo/tamanho e retorna 400 estruturado para valores inválidos, preservando o default 20 e o máximo 10.000.

Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/integration/files-api-limit-validation.test.ts — 5/5 passando
- tests/integration/files-api.test.ts — 12/12 passando (sem regressão)
- tests/unit/batch_api.test.ts teve 1 falha, confirmada DRIFT pré-existente idêntica no tip puro do release (não relacionada, timing de cancelamento de batch)
2026-08-20 19:56:34 -03:00
Diego Rodrigues de Sa e Souza
ce6249cbb7 Merge pull request #10528 from excessivechaos/fix/direct-dispatcher-timeout-10214
fix(network): bound direct-path response-start timeout and retry on fresh socket (#10214)
2026-08-20 19:55:57 -03:00
Diego Rodrigues de Sa e Souza
9935f80971 fix(perplexity-web): make the built-in-search hint opt-in (#10904)
Merged — extraction of the one still-uncovered fix from #8634 (the other two items — mode "search"→CONCISE downgrade, pplx-opus generation — were already applied on this release tip). typecheck/file-size/changelog/complexity/cognitive-complexity gates all clean, 32/32 tests passing.
2026-08-20 19:34:18 -03:00
Diego Rodrigues de Sa e Souza
e968d11b1c feat(home): add Recent Requests panel + excludeTests allowlist fix (#10900)
Merged — reimplementation extracting the non-conflicting Recent Requests panel + excludeTests allowlist fix from #8450 (see PR body for the full scoping rationale, including why the topology UX rework was deliberately excluded — it contradicts the already-shipped #8428). typecheck/file-size/changelog/complexity/cognitive-complexity/i18n-coverage gates all clean, 2/2 unit + 1/1 vitest passing.
2026-08-20 18:26:02 -03:00
excessivechaos
142ae93498 fix(network): bound direct-path response-start timeout 2026-08-17 08:26:57 -07:00
26 changed files with 1115 additions and 81 deletions

View File

@@ -1361,6 +1361,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
# FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s)
# OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS=30000 # Bounded response-start window per direct
# # (no-proxy) attempt (#10214). A silently-dropped
# # pooled keep-alive socket surfaces no transport
# # error, so without this bound a direct request can
# # stall until undici's headersTimeout (600s) or the
# # caller's deadline; on expiry the request retries
# # once on a fresh no-keep-alive socket. 0 disables
# # the bound (default: 30000 = 30s).
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
@@ -1415,6 +1423,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
# ── Perplexity web: built-in-search hint ──
# Used by: open-sse/executors/perplexity-web/protocol.ts — appends "You have
# built-in web search. Answer questions directly using search results." to the
# caller's system message. Off by default: Perplexity's answer engine searches
# anyway, and for coding clients the sentence leaks into replies as
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
# OMNIROUTE_PPLX_SEARCH_HINT=0
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on

View File

@@ -0,0 +1 @@
- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935

View File

@@ -0,0 +1 @@
- **fix(network):** direct (no-proxy) egress now bounds each attempt's response-start window (default 30s, `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS`) and retries once on a fresh no-keep-alive socket, so a silently-dropped pooled keep-alive connection can no longer stall direct providers (opencode-go, command-code) until a service restart ([#10214](https://github.com/diegosouzapw/OmniRoute/issues/10214))

View File

@@ -0,0 +1 @@
- **fix(sse):** Include the redacted upstream error body in the per-target COMBO failure log (`Model X failed, trying next`) so operators can triage a 400/500 without reproducing the request ([#10597](https://github.com/diegosouzapw/OmniRoute/issues/10597))

View File

@@ -0,0 +1 @@
- **fix(perplexity-web):** make the built-in-search hint appended to every system message opt-in via `OMNIROUTE_PPLX_SEARCH_HINT` (off by default) — Perplexity's answer engine searches anyway, and the hint leaked into replies as meta-commentary for coding clients ([#10902](https://github.com/diegosouzapw/OmniRoute/pull/10902), extracted from [#8634](https://github.com/diegosouzapw/OmniRoute/pull/8634)) — thanks @danscMax

View File

@@ -0,0 +1 @@
- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors

View File

@@ -732,6 +732,7 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY` | `true` | Enable early stream recovery automatically for detected `/goal` agent runs. Set `false`/`0`/`off` to disable the goal-specific opt-in. This can only ADD recovery on top of the operator default — it never overrides an explicit `STREAM_RECOVERY_ENABLED`/DB settings opt-out. |
| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(off)_ | Strip non-standard `codex.*` SSE events (e.g. `codex.rate_limits`) that break the OpenAI SDK's `responses.stream()` with a 502. Set `true`/`1`/`yes` to enable. |
| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. |
| `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` | `30000` (30s) | Maximum response-start wait (ms) for each direct no-proxy attempt. A timeout retries once on a fresh socket; set `0` to disable the bound and retain the previous behavior. |
| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. |
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. |
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. |
@@ -755,6 +756,7 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). |
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_PPLX_SEARCH_HINT` | `0` (off) | Appends "You have built-in web search. Answer questions directly using search results." to the caller's system message (`perplexity-web/protocol.ts`). Off by default — Perplexity searches anyway, and the sentence leaks into replies as meta-commentary for coding clients. Set `1`/`true`/`yes`/`on` to restore. |
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). |
| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`notionTlsClient.ts`); the `notion-web` executor raises it per-request to `180000` for long generations. |

View File

@@ -370,15 +370,29 @@ export function buildPplxRequestBody(
};
}
const SEARCH_HINT = "You have built-in web search. Answer questions directly using search results.";
/**
* Whether to append {@link SEARCH_HINT} to the caller's system message.
*
* It used to be unconditional. Perplexity's answer engine is search-first anyway, and
* for coding clients the sentence leaks into replies as meta-commentary ("I need to
* search before responding per my instructions"), so it is now opt-in via
* `OMNIROUTE_PPLX_SEARCH_HINT`. Read per call rather than at module load so the flag
* can be flipped without restarting the server (and so tests can toggle it).
*/
function searchHintEnabled(): boolean {
return /^(1|true|yes|on)$/i.test(process.env.OMNIROUTE_PPLX_SEARCH_HINT ?? "");
}
export function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string {
if (followUpUuid) return parsed.currentMsg;
const obj: Record<string, unknown> = {};
if (parsed.systemMsg.trim()) {
obj.instructions = [
parsed.systemMsg.trim(),
"You have built-in web search. Answer questions directly using search results.",
];
obj.instructions = searchHintEnabled()
? [parsed.systemMsg.trim(), SEARCH_HINT]
: [parsed.systemMsg.trim()];
}
if (parsed.history.length > 0) {
obj.history = parsed.history;

View File

@@ -2275,7 +2275,10 @@ async function handleComboChatInner({
);
}
}
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
log.warn("COMBO", `Model ${modelStr} failed, trying next`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
});
// #5976: per-model-quota providers (Gemini, GitHub, etc.) multiplex models
// behind one connection. A model-level 500 or 429 (RPM) must NOT cool down
@@ -3460,7 +3463,10 @@ async function handleRoundRobinCombo({
kind: classifyComboOutcome(result.status, errorText),
});
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
});
if (
resilienceSettings.providerCooldown.enabled &&

View File

@@ -15,6 +15,8 @@ import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts";
import {
buildChangedToolNameMap,
buildHistoricalToolResultContext,
mergeConsecutiveSameRoleContents,
type GeminiContent,
} from "./openai-to-gemini/helpers.ts";
/**
@@ -45,7 +47,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
: null;
const result: {
model: string;
contents: Array<Record<string, unknown>>;
contents: GeminiContent[];
generationConfig: Record<string, unknown>;
safetySettings: unknown;
systemInstruction?: { role: string; parts: Array<{ text: string }> };
@@ -314,6 +316,11 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
result._toolNameMap = changedToolNameMap;
}
// Gemini strictly rejects requests containing consecutive messages with the same role
// (400 INVALID_ARGUMENT: "Request contains consecutive messages with the same role").
// Normalize adjacent same-role messages by concatenating their parts.
result.contents = mergeConsecutiveSameRoleContents(result.contents);
return result;
}

View File

@@ -39,8 +39,13 @@ import {
escapeHistoricalContextAttribute,
escapeHistoricalContextContent,
buildHistoricalToolResultContext,
type GeminiPart,
type GeminiContent,
mergeConsecutiveSameRoleContents,
} from "./openai-to-gemini/helpers.ts";
export { mergeConsecutiveSameRoleContents, type GeminiContent, type GeminiPart };
// Observed Antigravity wrapper output cap, not an underlying model capability.
// Keep this bridge-local: Antigravity currently caps visible output around 16K.
// See: https://github.com/keisksw/antigravity-output-analysis
@@ -56,9 +61,6 @@ const GEMINI_BUILTIN_TOOL_NAMES = new Set<string>([
"googleSearch",
]);
type GeminiPart = Record<string, unknown>;
type GeminiContent = { role: string; parts: GeminiPart[] };
type GeminiFunctionDeclaration = {
name: string;
description: string;
@@ -158,29 +160,6 @@ type GeminiToolNameOptions = {
supportsSignatureBypass?: boolean;
};
// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that
// has two adjacent entries with the same role:
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
// Client history that carries consecutive user turns — or a tool-result turn (mapped
// to role:"user") immediately followed by a plain user turn — would otherwise leak
// that invalid alternation through. Merge adjacent same-role entries by concatenating
// their parts, the same normalization the Kiro and Claude request paths already apply
// (9router#2191).
export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] {
const merged: GeminiContent[] = [];
for (const entry of contents) {
const last = merged[merged.length - 1];
if (last && last.role === entry.role) {
last.parts.push(...entry.parts);
} else {
// Shallow-copy the entry and its `parts` array so a later same-role merge
// (`last.parts.push(...)`) never mutates the caller's input objects.
merged.push({ ...entry, parts: [...entry.parts] });
}
}
return merged;
}
// Core: Convert OpenAI request to Gemini format (base for all variants)
function openaiToGeminiBase(
model: string,

View File

@@ -152,3 +152,29 @@ export function buildHistoricalToolResultContext(name: string, response: unknown
"</previous_tool_result_context>",
].join("\n");
}
export type GeminiPart = Record<string, unknown>;
export type GeminiContent = { role: string; parts: GeminiPart[] };
// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that
// has two adjacent entries with the same role:
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
// Client history that carries consecutive user turns — or a tool-result turn (mapped
// to role:"user") immediately followed by a plain user turn — would otherwise leak
// that invalid alternation through. Merge adjacent same-role entries by concatenating
// their parts, the same normalization the Kiro and Claude request paths already apply
// (9router#2191).
export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] {
const merged: GeminiContent[] = [];
for (const entry of contents) {
const last = merged[merged.length - 1];
if (last && last.role === entry.role) {
last.parts.push(...entry.parts);
} else {
// Shallow-copy the entry and its `parts` array so a later same-role merge
// (`last.parts.push(...)`) never mutates the caller's input objects.
merged.push({ ...entry, parts: [...entry.parts] });
}
}
return merged;
}

View File

@@ -0,0 +1,77 @@
type DirectFetchOptions = RequestInit & { dispatcher?: unknown };
type DirectFetch = (
input: RequestInfo | URL,
options: DirectFetchOptions
) => Promise<Response>;
const DEFAULT_DIRECT_HEADERS_TIMEOUT_MS = 30_000;
const DIRECT_RESPONSE_START_TIMEOUT_CODE = "DIRECT_RESPONSE_START_TIMEOUT";
export function resolveDirectHeadersTimeoutMs(
env: Record<string, string | undefined> = process.env
): number {
const raw = env.OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS;
if (raw == null || raw.trim() === "") return DEFAULT_DIRECT_HEADERS_TIMEOUT_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
}
function createDirectResponseStartTimeout(timeoutMs: number): Error & { code: string } {
const err = new Error(
`Direct response did not start within ${timeoutMs}ms — retrying on a fresh socket`
) as Error & { code: string };
err.name = "TimeoutError";
err.code = DIRECT_RESPONSE_START_TIMEOUT_CODE;
return err;
}
export function isDirectResponseStartTimeout(err: unknown): boolean {
return (
!!err &&
typeof err === "object" &&
"code" in err &&
err.code === DIRECT_RESPONSE_START_TIMEOUT_CODE
);
}
function mergeAbortSignals(
primary: AbortSignal | null | undefined,
secondary: AbortSignal
): AbortSignal {
if (!primary) return secondary;
if (primary.aborted) return primary;
const controller = new AbortController();
const onPrimaryAbort = () => controller.abort(primary.reason);
const onSecondaryAbort = () => controller.abort(secondary.reason);
const cleanup = () => {
primary.removeEventListener("abort", onPrimaryAbort);
secondary.removeEventListener("abort", onSecondaryAbort);
};
primary.addEventListener("abort", onPrimaryAbort, { once: true });
secondary.addEventListener("abort", onSecondaryAbort, { once: true });
controller.signal.addEventListener("abort", cleanup, { once: true });
return controller.signal;
}
export async function directFetchWithBoundedResponseStart(
input: RequestInfo | URL,
options: DirectFetchOptions,
fetchImpl: DirectFetch,
timeoutMs: number
): Promise<Response> {
if (!timeoutMs || timeoutMs <= 0) return fetchImpl(input, options);
const attemptController = new AbortController();
const timer = setTimeout(
() => attemptController.abort(createDirectResponseStartTimeout(timeoutMs)),
timeoutMs
);
timer.unref?.();
try {
return await fetchImpl(input, {
...options,
signal: mergeAbortSignals(options.signal, attemptController.signal),
});
} finally {
clearTimeout(timer);
}
}

View File

@@ -19,6 +19,11 @@ import {
isControlPlaneProxyDirectFallbackEnabled,
isFeatureFlagEnabled,
} from "@/shared/utils/featureFlags";
import {
directFetchWithBoundedResponseStart,
isDirectResponseStartTimeout,
resolveDirectHeadersTimeoutMs,
} from "./directResponseStartTimeout.ts";
// #9100: relay egress (Vercel / Deno / Cloudflare edge functions) used to go
// through bare `originalFetch` — NO connection pooling, NO timeout, NO retry.
@@ -154,7 +159,6 @@ type TlsFingerprintStore = {
provider?: string | null;
sessionScope?: string;
};
/**
* #5217 (Gap-secondary): a mutable sink that records the proxy actually applied
* by `runWithProxyContext` for the in-flight request. Executors that pin their
@@ -802,15 +806,7 @@ async function patchedFetch(
(deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher;
return _nativeFetch(input, options);
}
// Direct connection (no proxy) — use undici with custom dispatcher for timeout control.
// Falls back to original native fetch if dispatcher initialization fails (#1054).
// Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry).
//
// Non-replayable body guard: if the body is stream-like (ReadableStream/Blob)
// or the input is a Request that carries a body, the first dispatcher attempt
// owns that body. Retrying or falling back to native fetch would replay a
// consumed/locked body and can mask the original transport error with
// "Response body object should not be disturbed or locked".
// Direct undici path: bound response-start, fresh-socket retry, and body guard.
const hasNonReplayableBody = requestHasNonReplayableBody(input, options);
const maxAttempts = hasNonReplayableBody ? 1 : 2;
const _undiciDirect =
@@ -818,32 +814,44 @@ async function patchedFetch(
const _nativeFallback =
(deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher;
let lastDispatcherError: unknown = null;
const directHeadersTimeoutMs = resolveDirectHeadersTimeoutMs();
let targetHostForLogs = "";
try {
targetHostForLogs = new URL(targetUrl).host;
} catch {
// ignore — logging is best-effort
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await _undiciDirect(input, {
...options,
// #4252: first attempt uses the pooled keep-alive dispatcher; a retry
// (after a transient socket error) uses the no-keep-alive dispatcher so
// it opens a FRESH socket instead of grabbing another stale pooled one
// — the burst pattern was the retry re-hitting a dead pooled socket and
// then falling through to native fetch (which also pools) → 502.
dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(),
});
return await directFetchWithBoundedResponseStart(
input,
{
...options,
dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(),
},
_undiciDirect,
directHeadersTimeoutMs
);
} catch (dispatcherError) {
if (isDirectResponseStartTimeout(dispatcherError)) {
if (attempt === 0 && maxAttempts > 1) {
console.warn(
`[ProxyFetch] Direct response-start timeout (${directHeadersTimeoutMs}ms) on pooled dispatcher — retrying on fresh no-keep-alive dispatcher: ${targetHostForLogs}`
);
lastDispatcherError = dispatcherError;
continue;
}
throw dispatcherError;
}
const msg =
dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError);
// CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart)
// because the native fetch will definitely fail with the undici v8 dispatcher.
if (msg.includes("onRequestStart")) {
console.error(
`[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}`
);
throw dispatcherError;
}
// Only retry/fallback for connection/dispatcher errors, not HTTP errors.
// Prefer the .code property when available (more stable across undici
// versions than message-string matching); fall back to substring match
// for errors that lack a structured code.
// Retry/fallback only for connection errors, never HTTP errors.
tagProxyUnreachable(dispatcherError);
const errCode = (dispatcherError as { code?: unknown })?.code;
if (
@@ -854,10 +862,7 @@ async function patchedFetch(
msg.includes("UND_ERR")
) {
if (attempt === 0 && maxAttempts > 1) {
// First failure — retry once after a short backoff before giving up.
// Delay is OMNIROUTE_RETRY_BACKOFF_MS (default 10ms): a fixed backoff
// beats random jitter here because the retry opens a fresh socket, so
// jitter was pure added latency with no herd benefit.
// Retry after a short fixed backoff on a fresh socket.
lastDispatcherError = dispatcherError;
await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS));
continue;
@@ -873,7 +878,7 @@ async function patchedFetch(
throw tagProxyUnreachable(dispatcherError);
}
// All attempts exhausted — try proxy fallback before native fetch
// Exhausted attempts: try proxy fallback before native fetch.
if (
!tlsDirectFallback &&
source === "direct" &&
@@ -899,20 +904,14 @@ async function patchedFetch(
}
}
}
// Preserve original phrase intact for monitoring: "Undici dispatcher failed, falling back to native fetch"
// #4252: append the flattened err.cause (code/syscall/errno/address) — the bare
// "fetch failed" message hides what actually broke, making bursts undiagnosable.
// Preserve the original monitoring phrase and append the transport cause.
console.warn(
`[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${describeFetchCause(dispatcherError)}`
);
try {
return await _nativeFallback(input, options);
} catch (nativeError) {
// #4252: both the undici dispatcher AND native fetch failed. Surface BOTH
// causes (server log) and tag the propagated error so the combo executor sees
// a diagnosable failure IMMEDIATELY instead of a bare "fetch failed" — the
// latter left jobs sitting until the 30s semaphore queue timeout, which then
// tripped the circuit breaker.
// Surface both dispatcher and native causes immediately.
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`;
console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`);
if (nativeError instanceof Error) {

View File

@@ -19,6 +19,7 @@ import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron";
import { HomeProviderTopologySection } from "./HomeProviderTopologySection";
import { shouldShowProviderTopologyOnHome } from "./homeAppearance";
import HomeRecentRequests from "../home/HomeRecentRequests";
type UpdateStep = {
step: string;
@@ -1126,12 +1127,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
)}
{showProviderTopologyOnHome && (
<HomeProviderTopologySection
providers={topologyProviders}
lastProvider={lastProvider}
errorProvider={errorProvider}
enabled={showProviderTopologyOnHome}
/>
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-3">
<HomeProviderTopologySection
providers={topologyProviders}
lastProvider={lastProvider}
errorProvider={errorProvider}
enabled={showProviderTopologyOnHome}
/>
<HomeRecentRequests enabled={showProviderTopologyOnHome} />
</div>
)}
{/* Provider Models Modal */}

View File

@@ -0,0 +1,207 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { fmtCompact } from "@/shared/utils/formatting";
/**
* Home-page "Recent Requests" panel — the live request feed that sits beside the
* Provider Topology graph (parity with 9Router's Usage view).
*
* ## Data source
* Fed by POLLING `GET /api/usage/call-logs?limit=N` every ~3s, NOT by the live
* WebSocket. The WS is used elsewhere for the in-flight beam (it emits
* `request.started` and, since the stuck-latch fix, `request.completed`/`request.failed`),
* but its payload carries no tokens/latency/status and no persisted history — so it
* can't back a "recent requests" table on its own. The call-logs endpoint merges the
* in-memory active/completed entries with persisted rows and returns them newest-first
* (active on top), which is exactly this feed.
*
* The poll is gated by `enabled` (the same `showProviderTopologyOnHome` flag that
* gates the topology section) AND page visibility, so a backgrounded tab pauses.
*/
const POLL_INTERVAL_MS = 3000;
// Rows shown after client-side filtering of connection-test rows.
const RECENT_LIMIT = 20;
// Fetch a wider window than we display so filtering out connection-test rows
// (a burst of "Test connection" clicks) can't starve the feed below RECENT_LIMIT.
const FETCH_LIMIT = 60;
type CallLogRow = {
id?: string;
timestamp?: string;
status?: number;
model?: string;
provider?: string;
providerDisplay?: string | null;
path?: string;
sourceFormat?: string | null;
targetFormat?: string | null;
tokens?: { in?: number; out?: number };
error?: string | null;
active?: boolean;
completed?: boolean;
};
/**
* Connection tests write real `call_logs` rows (provider "Test connection" button →
* `/api/providers/[id]/test`) with fixed markers: model `connection-test`, path
* `/api/providers/test`, sourceFormat/targetFormat `test`. Those are health probes,
* not user traffic, so they must not clutter the Recent Requests feed (matching how
* 9Router keeps its Usage list to real calls). Drop any row carrying a test marker.
*/
function isConnectionTestRow(row: CallLogRow): boolean {
return (
row.model === "connection-test" ||
row.sourceFormat === "test" ||
row.targetFormat === "test" ||
row.path === "/api/providers/test"
);
}
type RequestState = "active" | "error" | "ok";
function requestState(row: CallLogRow): RequestState {
if (row.active || row.status === 0) return "active";
if (row.error || (typeof row.status === "number" && row.status >= 400)) return "error";
return "ok";
}
function timeAgo(timestamp: string | undefined, nowMs: number): string {
if (!timestamp) return "";
const then = Date.parse(timestamp);
if (!Number.isFinite(then)) return "";
const diff = Math.max(0, Math.floor((nowMs - then) / 1000));
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
const STATE_DOT: Record<RequestState, string> = {
active: "bg-primary animate-pulse",
error: "bg-red-500",
ok: "bg-green-500",
};
export default function HomeRecentRequests({ enabled = true }: { enabled?: boolean }) {
const t = useTranslations("home");
const [rows, setRows] = useState<CallLogRow[]>([]);
const [loaded, setLoaded] = useState(false);
// A ticking clock so the relative "When" column updates without re-fetching.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!enabled) return;
const id = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(id);
}, [enabled]);
const load = useCallback(async (signal: AbortSignal) => {
try {
const res = await fetch(`/api/usage/call-logs?limit=${FETCH_LIMIT}&excludeTests=1`, {
cache: "no-store",
signal,
});
if (!res.ok) return;
const data = await res.json();
if (signal.aborted) return;
const filtered = Array.isArray(data)
? (data as CallLogRow[]).filter((row) => !isConnectionTestRow(row)).slice(0, RECENT_LIMIT)
: [];
setRows(filtered);
setLoaded(true);
} catch (error) {
const isAbort = error instanceof DOMException && error.name === "AbortError";
if (!isAbort) console.error("Failed to load recent requests:", error);
}
}, []);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let controller: AbortController | null = null;
const tick = async () => {
// Pause polling while the tab is backgrounded; resume on next tick.
if (document.visibilityState === "visible") {
const currentController = new AbortController();
controller = currentController;
await load(currentController.signal);
if (controller === currentController) controller = null;
}
if (!cancelled) timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
};
tick();
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
controller?.abort();
};
}, [enabled, load]);
return (
<Card padding="sm" className="flex min-w-0 flex-col overflow-hidden h-[300px] sm:h-[420px]">
<div className="pb-2 mb-1 border-b border-border shrink-0">
<span className="text-xs font-semibold uppercase tracking-wide text-text-muted">
{t("recentRequests")}
</span>
</div>
{loaded && rows.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-text-muted">
{t("recentRequestsEmpty")}
</div>
) : (
<div className="flex-1 overflow-y-auto -mx-1 px-1">
<table className="w-full min-w-0 border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-surface">
<tr className="border-b border-border text-text-muted">
<th className="w-2 py-1.5" />
<th className="py-1.5 text-left font-semibold">{t("recentRequestsModel")}</th>
<th className="py-1.5 text-right font-semibold whitespace-nowrap">
{t("recentRequestsTokens")}
</th>
<th className="py-1.5 text-right font-semibold">{t("recentRequestsWhen")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{rows.map((row, i) => {
const state = requestState(row);
return (
<tr key={row.id || i} className="hover:bg-bg-subtle transition-colors">
<td className="py-1.5">
<span className={`block size-1.5 rounded-full ${STATE_DOT[state]}`} />
</td>
<td
className="py-1.5 font-mono truncate max-w-[140px]"
title={row.model || ""}
>
{row.model || "—"}
</td>
<td className="py-1.5 text-right whitespace-nowrap">
<span className="text-primary">{fmtCompact(row.tokens?.in)}</span>{" "}
<span className="text-green-500">{fmtCompact(row.tokens?.out)}</span>
</td>
<td className="py-1.5 text-right whitespace-nowrap text-text-muted">
{state === "active" ? (
<span className="text-primary"></span>
) : (
timeAgo(row.timestamp, nowMs)
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
);
}

View File

@@ -143,6 +143,9 @@ export async function GET(request: Request) {
if (searchParams.get("correlationId")) filter.correlationId = searchParams.get("correlationId");
if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit"));
if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset"));
// Home Recent Requests feed sets excludeTests=1 so connection-test probe rows
// are dropped at the SQL layer (before LIMIT), not client-side after slicing.
if (searchParams.get("excludeTests") === "1") filter.excludeTests = true;
const [logs, connections, providerNodes] = await Promise.all([
getCallLogs(filter),

View File

@@ -7,6 +7,61 @@ export async function OPTIONS() {
return handleCorsOptions();
}
const DEFAULT_LIST_LIMIT = 20;
const MAX_LIST_LIMIT = 10000;
export function parseFilesListQuery(searchParams: URLSearchParams):
| {
ok: true;
limit: number;
after: string | undefined;
order: "asc" | "desc";
purpose: string | undefined;
}
| { ok: false; response: Response } {
const rawLimit = searchParams.get("limit");
let limit = DEFAULT_LIST_LIMIT;
if (rawLimit !== null) {
if (!/^\d+$/.test(rawLimit)) {
return {
ok: false,
response: NextResponse.json(
{ error: { message: "limit must be a positive integer", type: "invalid_request_error" } },
{ status: 400, headers: CORS_HEADERS }
),
};
}
limit = Number.parseInt(rawLimit, 10);
if (limit < 1 || limit > MAX_LIST_LIMIT) {
return {
ok: false,
response: NextResponse.json(
{
error: {
message: `limit must be between 1 and ${MAX_LIST_LIMIT}`,
type: "invalid_request_error",
},
},
{ status: 400, headers: CORS_HEADERS }
),
};
}
}
const orderParam = searchParams.get("order");
const order = orderParam === "asc" ? "asc" : "desc";
return {
ok: true,
limit,
after: searchParams.get("after") || undefined,
order,
purpose: searchParams.get("purpose") || undefined,
};
}
export async function POST(request: Request) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
@@ -78,10 +133,9 @@ export async function GET(request: Request) {
const apiKeyId = scope.apiKeyId;
const { searchParams } = new URL(request.url);
const limit = Math.min(Number.parseInt(searchParams.get("limit") || "20") || 20, 10000);
const after = searchParams.get("after") || undefined;
const order = (searchParams.get("order") as "asc" | "desc") || "desc";
const purpose = searchParams.get("purpose") || undefined;
const parsed = parseFilesListQuery(searchParams);
if (!parsed.ok) return parsed.response;
const { limit, after, order, purpose } = parsed;
// We fetch limit + 1 to check if there are more items
const files = listFiles({

View File

@@ -1800,6 +1800,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "Reloading page automatically...",
"providerTopology": "Provider Topology",
"recentRequests": "Recent Requests",
"recentRequestsEmpty": "No requests yet.",
"recentRequestsModel": "Model",
"recentRequestsTokens": "In / Out",
"recentRequestsWhen": "When",
"downloadDmg": "Download DMG (macOS)",
"downloadDmgDescription": "A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v{version}).",
"downloadExe": "Download EXE (Windows)",

View File

@@ -700,6 +700,14 @@ export async function getCallLogs(filter: any = {}) {
if (filter.combo) {
conditions.push("cl.combo_name IS NOT NULL");
}
if (filter.excludeTests) {
// Home "Recent Requests" is an allowlist of real provider inference, not a
// blacklist of known backend log types. Persisted provider requests enter via
// the public gateway namespaces (/v1/* or /api/v1/*); internal management work
// (connection tests, model sync, and future /api/providers/* jobs) does not.
// Apply this before LIMIT so backend rows can never displace real traffic.
conditions.push(`(cl.path LIKE '/v1/%' OR cl.path LIKE '/api/v1/%')`);
}
if (filter.since) {
conditions.push("cl.timestamp >= @since");
params.since = filter.since instanceof Date ? filter.since.toISOString() : String(filter.since);

View File

@@ -0,0 +1,77 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { createFile, deleteFile } from "@/lib/db/files";
import { GET, parseFilesListQuery } from "@/app/api/v1/files/route";
describe("GET /v1/files limit validation", () => {
it("defaults to 20 when limit is absent", () => {
const parsed = parseFilesListQuery(new URLSearchParams("order=asc"));
assert.equal(parsed.ok, true);
if (!parsed.ok) return;
assert.equal(parsed.limit, 20);
});
it("parses an explicit positive integer limit", () => {
const parsed = parseFilesListQuery(new URLSearchParams("limit=2&order=asc&purpose=batch"));
assert.equal(parsed.ok, true);
if (!parsed.ok) return;
assert.equal(parsed.limit, 2);
assert.equal(parsed.order, "asc");
assert.equal(parsed.purpose, "batch");
});
it("rejects non-integer, zero, and oversized limits", async () => {
for (const rawLimit of ["abc", "1.5", "-1", "0", "10001"]) {
const parsed = parseFilesListQuery(
new URLSearchParams(`limit=${encodeURIComponent(rawLimit)}`)
);
assert.equal(parsed.ok, false, `limit=${rawLimit} should be rejected`);
if (parsed.ok) continue;
assert.equal(parsed.response.status, 400);
const body = await parsed.response.json();
assert.equal(body.error.type, "invalid_request_error");
}
});
it("returns only the requested number of files over HTTP", async () => {
const created = [
createFile({
bytes: 1,
filename: "test-files-limit-http-a.txt",
purpose: "assistants",
content: Buffer.from("a"),
mimeType: "text/plain",
}),
createFile({
bytes: 1,
filename: "test-files-limit-http-b.txt",
purpose: "assistants",
content: Buffer.from("b"),
mimeType: "text/plain",
}),
];
try {
const response = await GET(
new Request("http://localhost/v1/files?limit=1&purpose=assistants")
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.object, "list");
assert.equal(body.data.length, 1);
assert.equal(body.has_more, true);
} finally {
for (const file of created) deleteFile(file.id);
}
});
it("returns 400 over HTTP for an invalid limit instead of listing files", async () => {
const response = await GET(new Request("http://localhost/v1/files?limit=-1"));
assert.equal(response.status, 400);
const body = await response.json();
assert.equal(body.error.type, "invalid_request_error");
});
});

View File

@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Home "Recent Requests" feed passes `excludeTests` to getCallLogs so the panel shows
* ONLY real provider inference — never backend/management log rows. The filter is an
* ALLOWLIST of the public gateway namespaces (`/v1/%` and `/api/v1/%`), applied before
* LIMIT, rather than a blacklist of individual known noise types. This guards against
* the reported regression where model-sync rows (request_type 'model-sync', path
* `/api/providers/*`) leaked into the feed because the old blacklist only dropped
* connection-test rows.
*/
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-allowlist-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.CALL_LOG_RETENTION_DAYS = "3650";
const core = await import("../../src/lib/db/core.ts");
const callLogs = await import("../../src/lib/usage/callLogs.ts");
type SeedRow = {
id: string;
timestamp: string;
path: string;
model: string;
provider: string;
source_format?: string;
request_type?: string | null;
};
function insertCallLog(row: SeedRow) {
const db = core.getDbInstance();
db.prepare(
`
INSERT INTO call_logs (
id, timestamp, method, path, status, model, provider, source_format, request_type, detail_state
)
VALUES (
@id, @timestamp, 'POST', @path, 200, @model, @provider, @source_format, @request_type, 'none'
)
`
).run({
source_format: row.source_format ?? null,
request_type: row.request_type ?? null,
...row,
});
}
test.beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("excludeTests keeps only /v1 and /api/v1 inference rows, drops all backend/management rows", async () => {
const base = Date.parse("2026-01-01T00:00:00.000Z");
const iso = (i: number) => new Date(base + i * 1000).toISOString();
// Two REAL provider inference rows — the only rows the feed should keep.
insertCallLog({
id: "real-v1",
timestamp: iso(4),
path: "/v1/chat/completions",
model: "openai/gpt-4.1",
provider: "openai",
});
insertCallLog({
id: "real-api-v1",
timestamp: iso(3),
path: "/api/v1/chat/completions",
model: "anthropic/claude-opus-4-8",
provider: "anthropic",
});
// Backend/management NOISE — must never appear in the feed.
insertCallLog({
id: "noise-model-sync",
timestamp: iso(2),
path: "/api/providers/openai/models",
model: "model-sync",
provider: "openai",
source_format: "-",
request_type: "model-sync",
});
insertCallLog({
id: "noise-connection-test",
timestamp: iso(1),
path: "/api/providers/test",
model: "connection-test",
provider: "openai",
source_format: "test",
});
const rows = await callLogs.getCallLogs({ excludeTests: true, limit: 50 });
const ids = rows.map((row) => row.id).sort();
assert.deepEqual(
ids,
["real-api-v1", "real-v1"],
"only the /v1 and /api/v1 inference rows survive the allowlist"
);
});
test("without excludeTests every row is returned (allowlist is opt-in)", async () => {
const base = Date.parse("2026-02-01T00:00:00.000Z");
insertCallLog({
id: "real",
timestamp: new Date(base).toISOString(),
path: "/v1/chat/completions",
model: "openai/gpt-4.1",
provider: "openai",
});
insertCallLog({
id: "sync",
timestamp: new Date(base + 1000).toISOString(),
path: "/api/providers/openai/models",
model: "model-sync",
provider: "openai",
request_type: "model-sync",
});
const rows = await callLogs.getCallLogs({ limit: 50 });
assert.equal(rows.length, 2, "no allowlist → backend rows are not filtered out");
});

View File

@@ -0,0 +1,150 @@
import test from "node:test";
import assert from "node:assert/strict";
const { claudeToGeminiRequest } =
await import("../../open-sse/translator/request/claude-to-gemini.ts");
test("Claude -> Gemini merges consecutive user text turns into a single user turn", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [
{ role: "user", content: "hello" },
{ role: "user", content: [{ type: "text", text: "world" }] },
],
},
false
);
assert.equal(result.contents.length, 1);
assert.equal(result.contents[0].role, "user");
assert.deepEqual(result.contents[0].parts, [{ text: "hello" }, { text: "world" }]);
});
test("Claude -> Gemini merges tool_result and subsequent user instruction into single user turn", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [
{ role: "user", content: "Calculate 2+2" },
{
role: "assistant",
content: [{ type: "text", text: "I will calculate that." }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_call_1",
content: "4",
},
],
},
{
role: "user",
content: "Now add 10 to that result",
},
],
},
false
);
// Contents must alternate properly and not have consecutive same-role turns
for (let i = 1; i < result.contents.length; i++) {
assert.notEqual(
result.contents[i].role,
result.contents[i - 1].role,
`Consecutive same-role detected at index ${i - 1} and ${i}: ${result.contents[i].role}`
);
}
// The last turn should be a merged user turn containing both the tool context and the text
const lastTurn = result.contents[result.contents.length - 1];
assert.equal(lastTurn.role, "user");
assert.equal(lastTurn.parts.length, 2);
assert.ok(
typeof (lastTurn.parts[0] as { text: string }).text === "string" &&
(lastTurn.parts[0] as { text: string }).text.includes("previous_tool_result_context")
);
assert.deepEqual(lastTurn.parts[1], { text: "Now add 10 to that result" });
});
test("Claude -> Gemini preserves alternating conversation turns without spurious merging", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi! How can I help?" },
{ role: "user", content: "What is the capital of France?" },
],
},
false
);
assert.equal(result.contents.length, 3);
assert.equal(result.contents[0].role, "user");
assert.deepEqual(result.contents[0].parts, [{ text: "Hello" }]);
assert.equal(result.contents[1].role, "model");
assert.deepEqual(result.contents[1].parts, [{ text: "Hi! How can I help?" }]);
assert.equal(result.contents[2].role, "user");
assert.deepEqual(result.contents[2].parts, [{ text: "What is the capital of France?" }]);
});
test("Claude -> Gemini merges three or more consecutive user turns into a single user turn", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [
{ role: "user", content: "part 1" },
{ role: "user", content: "part 2" },
{ role: "user", content: "part 3" },
],
},
false
);
assert.equal(result.contents.length, 1);
assert.equal(result.contents[0].role, "user");
assert.deepEqual(result.contents[0].parts, [
{ text: "part 1" },
{ text: "part 2" },
{ text: "part 3" },
]);
});
test("Claude -> Gemini handles empty messages array without error", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [],
},
false
);
assert.deepEqual(result.contents, []);
});
test("Claude -> Gemini merges consecutive assistant turns into a single model turn", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [
{ role: "user", content: "hello" },
{ role: "assistant", content: "response part 1" },
{ role: "assistant", content: [{ type: "text", text: "response part 2" }] },
],
},
false
);
assert.equal(result.contents.length, 2);
assert.equal(result.contents[0].role, "user");
assert.deepEqual(result.contents[0].parts, [{ text: "hello" }]);
assert.equal(result.contents[1].role, "model");
assert.deepEqual(result.contents[1].parts, [
{ text: "response part 1" },
{ text: "response part 2" },
]);
});

View File

@@ -0,0 +1,91 @@
/**
* #10597 — When a combo target fails with a non-2xx status, the per-target
* "Model X failed, trying next" COMBO log line only carries `{ status }` —
* the upstream error BODY (e.g. Anthropic's "prompt is too long" or a
* tool_use/tool_result pairing 400) is captured in `errorText` but never
* logged, so operators cannot distinguish failure causes from server logs
* without reproducing the request.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-10597-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-10597-test-secret";
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const DISTINCTIVE_ERROR_TEXT =
"messages.450: `tool_use` ids were found without `tool_result` blocks immediately after";
type WarnCall = { tag: string; msg: string; meta: unknown };
const warnCalls: WarnCall[] = [];
const log = {
info: () => {},
debug: () => {},
error: () => {},
warn: (tag: string, msg: string, meta?: unknown) => {
warnCalls.push({ tag, msg, meta });
},
};
function failing400() {
return new Response(
JSON.stringify({
type: "error",
error: { type: "invalid_request_error", message: DISTINCTIVE_ERROR_TEXT },
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
function healthy200(model: string) {
return new Response(
JSON.stringify({
id: "ok",
object: "chat.completion",
model,
choices: [{ index: 0, message: { role: "assistant", content: "hello from " + model }, finish_reason: "stop" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
function makeCombo(models: string[]) {
return { name: "test-combo-10597", strategy: "priority", models: models.map((m) => ({ model: m })) };
}
test("#10597 COMBO failure log must surface the upstream error body, not just the status code", async () => {
const modelsCalled: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
if (modelsCalled.length === 1) return failing400();
return healthy200(modelStr);
};
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["claude/claude-opus-4-8", "openai/gpt-4o-mini"]),
handleSingleModel,
log,
settings: {},
allCombos: [],
});
assert.equal(result.status, 200);
assert.equal(modelsCalled.length, 2);
const failureLog = warnCalls.find(
(c) => typeof c.msg === "string" && c.msg.includes("claude/claude-opus-4-8") && c.msg.includes("failed")
);
assert.ok(failureLog, "expected a COMBO warn log for the failing leg");
const serialized = JSON.stringify(failureLog);
assert.ok(
serialized.includes("tool_use") || serialized.includes(DISTINCTIVE_ERROR_TEXT),
`expected the upstream error body to appear in the COMBO failure log, but got: ${serialized}`
);
});

View File

@@ -912,6 +912,35 @@ test("Model mapping: thinking mode uses thinking variant", async () => {
}
});
// ─── The search hint is opt-in ──────────────────────────────────────────────
// It used to be appended to every system message and leaked into answers as
// meta-commentary, which is noise for coding clients.
test("buildQuery: search hint is off by default and opt-in via env", async () => {
const { buildQuery } = await import("../../open-sse/executors/perplexity-web/protocol.ts");
const parsed = { systemMsg: "You are terse.", history: [], currentMsg: "hi" };
const HINT = "built-in web search";
const prev = process.env.OMNIROUTE_PPLX_SEARCH_HINT;
try {
delete process.env.OMNIROUTE_PPLX_SEARCH_HINT;
const off = JSON.parse(buildQuery(parsed, null));
assert.deepEqual(off.instructions, ["You are terse."]);
assert.equal(off.query, "hi");
process.env.OMNIROUTE_PPLX_SEARCH_HINT = "1";
const on = JSON.parse(buildQuery(parsed, null));
assert.equal(on.instructions.length, 2);
assert.ok(on.instructions[1].includes(HINT));
process.env.OMNIROUTE_PPLX_SEARCH_HINT = "0";
assert.equal(JSON.parse(buildQuery(parsed, null)).instructions.length, 1);
} finally {
if (prev === undefined) delete process.env.OMNIROUTE_PPLX_SEARCH_HINT;
else process.env.OMNIROUTE_PPLX_SEARCH_HINT = prev;
}
});
// ─── Test: Live multi-step stream (no COMPLETED; text_completed + diffs) ────
test("Live multi-step: reconstructs answer without status COMPLETED", async () => {

View File

@@ -0,0 +1,143 @@
/**
* #10214 — Direct (no-proxy) requests stall on a silently-dropped pooled
* keep-alive socket until the caller's deadline or a service restart.
*
* The default direct dispatcher pools keep-alive sockets for up to
* `fetchKeepAliveTimeoutMs` (4 s). A socket that silently drops (half-open, no
* RST) surfaces NO transport error — undici's headersTimeout (600 s default) is
* the only guard, so the existing fresh-socket retry (which fires on
* UND_ERR/ECONNRESET/fetch-failed) never triggers. Observed live: opencode-go
* and command-code stall 100% of routed requests until `systemctl restart`.
*
* The fix bounds the response-start window per direct attempt
* (`OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS`, default 30 s) and retries once on the
* fresh no-keep-alive dispatcher (a brand-new socket) when the pooled attempt
* times out — converting the zombie-socket stall into a clean failover.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { proxyFetch } from "../../open-sse/utils/proxyFetch.ts";
import { getDefaultDispatcher, getRetryDispatcher } from "../../open-sse/utils/proxyDispatcher.ts";
const DIRECT_RESPONSE_START_TIMEOUT_CODE = "DIRECT_RESPONSE_START_TIMEOUT";
/** Simulates a silent half-open pooled socket: the request never resolves, but
* observes the abort signal like real undici does (rejects with the reason). */
function hangingFetch(capture: {
calls: number;
dispatchers: unknown[];
}): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return (input, init) => {
capture.calls++;
capture.dispatchers.push((init as { dispatcher?: unknown } | undefined)?.dispatcher);
return new Promise<Response>((_, reject) => {
const signal = init?.signal;
signal?.addEventListener(
"abort",
() =>
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason))),
{ once: true }
);
// never resolve — the upstream accepted the connection but sends nothing
});
};
}
function withFastTimeout<T>(fn: () => Promise<T>): Promise<T> {
process.env.OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS = "50";
return fn().finally(() => {
delete process.env.OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS;
});
}
test("#10214 a response-start timeout on the pooled attempt retries on the FRESH no-keep-alive dispatcher", async () => {
const capture = { calls: 0, dispatchers: [] as unknown[] };
const mockUndici = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
capture.calls++;
capture.dispatchers.push((init as { dispatcher?: unknown } | undefined)?.dispatcher);
if (capture.calls === 1) {
// First attempt hits a silently-dead pooled socket — hang, no error.
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true });
});
}
return new Response("ok", { status: 200 });
};
const mockNative = async (): Promise<Response> =>
new Response("native-should-not-fire", { status: 200 });
const res = await withFastTimeout(() =>
proxyFetch(
"https://opencode.ai/zen/go/v1/chat/completions",
{ method: "POST" },
{ undiciFetch: mockUndici, nativeFetch: mockNative }
)
);
assert.equal(capture.calls, 2, "pooled attempt times out and must retry once");
assert.equal(await res.text(), "ok");
// The regression guard: attempt 0 used the pooled keep-alive dispatcher; the
// retry used the fresh no-keep-alive dispatcher — a DIFFERENT instance, so the
// retry opens a brand-new socket that cannot be the zombie.
assert.equal(
capture.dispatchers[0],
getDefaultDispatcher(),
"first attempt must use the pooled default dispatcher"
);
assert.equal(
capture.dispatchers[1],
getRetryDispatcher(),
"timeout retry must use the fresh no-keep-alive dispatcher"
);
assert.notEqual(capture.dispatchers[0], capture.dispatchers[1]);
});
test("#10214 when the fresh-dispatcher retry also stalls, the timeout surfaces (no native fallback)", async () => {
const capture = { calls: 0, dispatchers: [] as unknown[] };
const mockUndici = hangingFetch(capture);
const mockNative = async (): Promise<Response> =>
new Response("native-should-not-fire", { status: 200 });
await assert.rejects(
withFastTimeout(() =>
proxyFetch(
"https://opencode.ai/zen/go/v1/chat/completions",
{ method: "POST" },
{ undiciFetch: mockUndici, nativeFetch: mockNative }
)
),
(err: unknown) => {
assert.equal(
(err as { code?: unknown }).code,
DIRECT_RESPONSE_START_TIMEOUT_CODE,
"final failure must be the classified direct response-start timeout"
);
return true;
}
);
assert.equal(capture.calls, 2, "both attempts must have been made");
assert.equal(capture.dispatchers[0], getDefaultDispatcher());
assert.equal(capture.dispatchers[1], getRetryDispatcher());
});
test("#10214 a healthy fast response is untouched by the bound (single attempt, no retry)", async () => {
const capture = { calls: 0, dispatchers: [] as unknown[] };
const mockUndici = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
capture.calls++;
capture.dispatchers.push((init as { dispatcher?: unknown } | undefined)?.dispatcher);
return new Response("ok", { status: 200 });
};
const res = await withFastTimeout(() =>
proxyFetch(
"https://opencode.ai/zen/go/v1/chat/completions",
{ method: "POST" },
{ undiciFetch: mockUndici }
)
);
assert.equal(capture.calls, 1, "healthy request must not retry");
assert.equal(capture.dispatchers[0], getDefaultDispatcher());
assert.equal(await res.text(), "ok");
});