{t("usageDescription")}
- {`curl http://localhost:20128/v1/relay/chat/completions \\
+ {`curl ${displayBaseUrl}/v1/relay/chat/completions \\
-H "Authorization: Bearer relay_..." \\
-H "Content-Type: application/json" \\
-d '{"model":"claude-sonnet-4","messages":[{"role":"user","content":"Hello"}]}'`}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
index e5decf84c5..dec2e60786 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
@@ -41,7 +41,8 @@ export default async function AgentBridgePage() {
try {
const base =
process.env.OMNIROUTE_BASE_URL ??
- `http://127.0.0.1:${process.env.PORT ?? 20128}`;
+ process.env.BASE_URL ??
+ `http://127.0.0.1:${process.env.DASHBOARD_PORT ?? process.env.PORT ?? 20128}`;
const res = await fetch(`${base}/api/tools/agent-bridge/state`, {
cache: "no-store",
headers: { "x-internal-fetch": "1" },
diff --git a/src/app/api/assess/route.ts b/src/app/api/assess/route.ts
index a8c83bedd4..0dd9a7e366 100644
--- a/src/app/api/assess/route.ts
+++ b/src/app/api/assess/route.ts
@@ -11,9 +11,18 @@ import {
import { validateBody } from "@/shared/validation/helpers";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
+function getAssessBaseUrl(): string {
+ return (
+ process.env.OMNIROUTE_BASE_URL ??
+ process.env.OMNIROUTe_BASE_URL ??
+ process.env.BASE_URL ??
+ `http://localhost:${process.env.API_PORT ?? process.env.PORT ?? 20128}/v1`
+ );
+}
+
const assessor = new Assessor(
- process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "",
- process.env.OMNIROUTe_BASE_URL ?? "http://localhost:20128/v1"
+ process.env.OMNIROUTE_API_KEY ?? process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "",
+ getAssessBaseUrl()
);
const categorizer = new Categorizer();
@@ -142,9 +151,12 @@ export async function GET(request: NextRequest) {
async function getAllModels(): Promise> {
try {
- const resp = await fetch("http://localhost:20128/v1/models", {
+ const baseUrl = getAssessBaseUrl();
+ const apiKey =
+ process.env.OMNIROUTE_API_KEY ?? process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? "";
+ const resp = await fetch(`${baseUrl}/models`, {
headers: {
- Authorization: `Bearer ${process.env.OMNIROUTe_API_KEY ?? process.env.API_KEY ?? ""}`,
+ Authorization: `Bearer ${apiKey}`,
},
});
const data = (await resp.json()) as { data?: unknown };
diff --git a/src/app/api/cli-tools/apply/route.ts b/src/app/api/cli-tools/apply/route.ts
index 303728d55d..5ef5fc0000 100644
--- a/src/app/api/cli-tools/apply/route.ts
+++ b/src/app/api/cli-tools/apply/route.ts
@@ -50,8 +50,14 @@ export async function POST(request: Request) {
const { toolId, baseUrl, apiKey, model, dryRun } = parsed.data;
const canonicalToolId = normalizeCliToolId(toolId);
+ const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
+ const defaultBaseUrl =
+ process.env.OMNIROUTE_BASE_URL ||
+ process.env.BASE_URL ||
+ `http://localhost:${defaultPort}/v1`;
+
const result = await generateConfig(canonicalToolId, {
- baseUrl: baseUrl || "http://localhost:20128/v1",
+ baseUrl: baseUrl || defaultBaseUrl,
apiKey,
model,
});
diff --git a/src/app/api/cli-tools/config/route.ts b/src/app/api/cli-tools/config/route.ts
index c986ca1db0..766b3374bd 100644
--- a/src/app/api/cli-tools/config/route.ts
+++ b/src/app/api/cli-tools/config/route.ts
@@ -16,7 +16,10 @@ export async function GET(request: Request) {
if (authError) return authError;
const { searchParams } = new URL(request.url);
- const baseUrl = searchParams.get("baseUrl") || "http://localhost:20128/v1";
+ const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
+ const defaultBaseUrl =
+ process.env.OMNIROUTE_BASE_URL || process.env.BASE_URL || `http://localhost:${defaultPort}/v1`;
+ const baseUrl = searchParams.get("baseUrl") || defaultBaseUrl;
const apiKey = searchParams.get("apiKey") || "";
if (!apiKey) {
@@ -46,9 +49,14 @@ export async function POST(request: Request) {
);
}
const { toolId, baseUrl, apiKey, model } = parsed.data;
+ const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
+ const defaultBaseUrl =
+ process.env.OMNIROUTE_BASE_URL ||
+ process.env.BASE_URL ||
+ `http://localhost:${defaultPort}/v1`;
const result = await generateConfig(toolId, {
- baseUrl: baseUrl || "http://localhost:20128/v1",
+ baseUrl: baseUrl || defaultBaseUrl,
apiKey,
model,
});
diff --git a/src/app/api/cli-tools/letta-settings/route.ts b/src/app/api/cli-tools/letta-settings/route.ts
index f10f1a42a7..10410daca4 100644
--- a/src/app/api/cli-tools/letta-settings/route.ts
+++ b/src/app/api/cli-tools/letta-settings/route.ts
@@ -74,7 +74,13 @@ const readAuthFile = async () => {
// ── Check if a base_url points to OmniRoute ──────────────────────────────
const isOmniRouteUrl = (baseUrl) => {
if (!baseUrl) return false;
- return baseUrl.includes(":20128") || baseUrl.includes(":3000") || baseUrl.includes("omniroute");
+ const port = process.env.PORT || process.env.DASHBOARD_PORT;
+ return (
+ baseUrl.includes(":20128") ||
+ baseUrl.includes(":3000") ||
+ (!!port && baseUrl.includes(`:${port}`)) ||
+ baseUrl.includes("omniroute")
+ );
};
// ── Check if OmniRoute is configured ─────────────────────────────────────
@@ -122,10 +128,7 @@ export async function GET(request: Request) {
backendMode: settings.preferredBackendMode || "api",
});
} catch (error) {
- return NextResponse.json(
- { error: { message: sanitizeErrorMessage(error) } },
- { status: 500 }
- );
+ return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
}
}
@@ -246,10 +249,7 @@ export async function POST(request: Request) {
needsRestart: true,
});
} catch (error) {
- return NextResponse.json(
- { error: { message: sanitizeErrorMessage(error) } },
- { status: 500 }
- );
+ return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
}
}
@@ -321,9 +321,6 @@ export async function DELETE(request: Request) {
needsRestart: true,
});
} catch (error) {
- return NextResponse.json(
- { error: { message: sanitizeErrorMessage(error) } },
- { status: 500 }
- );
+ return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 });
}
}
diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts
index 04f39a35b1..4c6d0d5bba 100644
--- a/src/app/api/combos/[id]/route.ts
+++ b/src/app/api/combos/[id]/route.ts
@@ -6,6 +6,7 @@ import { syncToCloud } from "@/lib/cloudSync";
import { validateCompositeTiersConfig } from "@/lib/combos/compositeTiers";
import { normalizeComboModels } from "@/lib/combos/steps";
import { validateComboDAG, clampComboDepth } from "@omniroute/open-sse/services/combo.ts";
+import { resolveCanonicalProviderModel } from "@omniroute/open-sse/services/model.ts";
import { updateComboSchema } from "@/shared/validation/schemas";
import { requiresQuotaOnlyComboRefExecute } from "@/shared/validation/schemas/combo";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -137,6 +138,32 @@ export async function PUT(request, { params }) {
}),
}
: normalizedUpdate;
+
+ if (body.overrideAllowedProviders === true) {
+ delete body.overrideAllowedProviders;
+ const currentProviders = Array.isArray(currentCombo.allowedProviders)
+ ? currentCombo.allowedProviders
+ : [];
+ // Only widen an EXISTING restriction (#13951/COMBO_008). When the combo
+ // currently has no allowedProviders restriction, currentProviders is
+ // empty and unioning it with the new step providers would synthesize a
+ // brand-new allowlist out of nothing — the opposite of "no restriction".
+ if (body.models && body.allowedProviders === undefined && currentProviders.length > 0) {
+ const stepProviders = (
+ body.models as Array<{ providerId?: string; provider?: string; model?: string }>
+ )
+ .map((m) => {
+ if (m.providerId) return m.providerId;
+ if (m.provider) return m.provider;
+ if (typeof m.model !== "string" || !m.model.includes("/")) return "";
+ const [aliasOrProvider, ...rest] = m.model.split("/");
+ return resolveCanonicalProviderModel(aliasOrProvider, rest.join("/")).provider || "";
+ })
+ .filter((p): p is string => Boolean(p));
+ body.allowedProviders = Array.from(new Set([...currentProviders, ...stepProviders]));
+ }
+ }
+
const nextComboState = {
...currentCombo,
...body,
diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts
index 51016b9f52..a1b7319e30 100644
--- a/src/app/api/keys/route.ts
+++ b/src/app/api/keys/route.ts
@@ -82,6 +82,7 @@ export async function POST(request) {
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
chaosModeEnabled,
+ expiresAt,
} = validation.data;
// Always get machineId from server
@@ -92,6 +93,7 @@ export async function POST(request) {
allowedModels,
allowedCombos,
allowedConnections,
+ expiresAt,
});
if (
noLog === true ||
@@ -137,6 +139,7 @@ export async function POST(request) {
dailyUsageLimitUsd: dailyUsageLimitUsd ?? null,
weeklyUsageLimitUsd: weeklyUsageLimitUsd ?? null,
chaosModeEnabled: chaosModeEnabled === true,
+ expiresAt: expiresAt ?? null,
streamDefaultMode: "legacy",
compressionEnabled: true,
cacheDefaultMode: "legacy",
diff --git a/src/app/api/playground/improve-prompt/route.ts b/src/app/api/playground/improve-prompt/route.ts
index daa1c9dc4c..21a2d6fe00 100644
--- a/src/app/api/playground/improve-prompt/route.ts
+++ b/src/app/api/playground/improve-prompt/route.ts
@@ -76,7 +76,7 @@ export async function POST(request: Request): Promise {
const chatBody = buildImproveChatBody(body);
// 5. Call /v1/chat/completions on ourselves (D8)
- const port = process.env.PORT ?? "20128";
+ const port = process.env.API_PORT ?? process.env.PORT ?? "20128";
const baseUrl = process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`;
const upstreamUrl = `${baseUrl}/v1/chat/completions`;
diff --git a/src/app/api/sync/cloud/route.ts b/src/app/api/sync/cloud/route.ts
index 44890fc510..a198a661ce 100644
--- a/src/app/api/sync/cloud/route.ts
+++ b/src/app/api/sync/cloud/route.ts
@@ -230,7 +230,8 @@ async function handleDisable(machineId: string, request: any) {
}
// Update Claude CLI settings to use local endpoint
- const host = request.headers.get("host") || "localhost:20128";
+ const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || "20128";
+ const host = request.headers.get("host") || `localhost:${defaultPort}`;
await updateClaudeSettingsToLocal(machineId, host);
return NextResponse.json({
diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts
index 1b5253e3b3..0eb0fee492 100644
--- a/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts
+++ b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts
@@ -15,7 +15,14 @@ interface Params {
params: Promise<{ id: string }>;
}
-const OMNIROUTE_BASE = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128";
+function getOmnirouteBaseUrl(): string {
+ const port = process.env.API_PORT || process.env.PORT || 20128;
+ return (
+ process.env.OMNIROUTE_BASE_URL ||
+ process.env.BASE_URL ||
+ `http://127.0.0.1:${port}`
+ ).replace(/\/+$/, "");
+}
export async function POST(_request: Request, { params }: Params): Promise {
const { id } = await params;
@@ -27,7 +34,7 @@ export async function POST(_request: Request, { params }: Params): Promise = {
"content-type": "application/json",
diff --git a/src/app/docs/components/ApiExplorerClient.tsx b/src/app/docs/components/ApiExplorerClient.tsx
index e1e25144d3..4ba113e0db 100644
--- a/src/app/docs/components/ApiExplorerClient.tsx
+++ b/src/app/docs/components/ApiExplorerClient.tsx
@@ -92,7 +92,9 @@ export function ApiExplorerClient() {
const t = useTranslations("docs");
const te = useTranslations("endpoint");
const [selected, setSelected] = useState(null);
- const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
+ const [baseUrl, setBaseUrl] = useState(
+ typeof window !== "undefined" ? window.location.origin : "http://localhost:20128"
+ );
const [apiKey, setApiKey] = useState("");
const [requestBody, setRequestBody] = useState("");
const [response, setResponse] = useState(null);
diff --git a/src/domain/assessment/assessor.ts b/src/domain/assessment/assessor.ts
index 0358f57007..163b005cc5 100644
--- a/src/domain/assessment/assessor.ts
+++ b/src/domain/assessment/assessor.ts
@@ -34,7 +34,9 @@ export class Assessor {
constructor(
apiKey: string,
- baseUrl: string = "http://localhost:20128/v1",
+ baseUrl: string = process.env.OMNIROUTE_BASE_URL ??
+ process.env.BASE_URL ??
+ `http://localhost:${process.env.API_PORT ?? process.env.PORT ?? 20128}/v1`,
config: Partial = {}
) {
this.apiKey = apiKey;
diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json
index 8f5f029db8..e5602201aa 100644
--- a/src/i18n/messages/am.json
+++ b/src/i18n/messages/am.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "የትስስር መታወቂያ፦ {id}",
"detailedPayloadInfo": "ለአዳዲስ ጥያቄዎች ባለአራት-ደረጃ የደንበኛ/አቅራቢ ውሂብ እይታን ከፈለጉ፣ መጀመሪያ ዝርዝር ምዝገባን ያንቁ።",
"copyAll": "ሁሉንም ቅዳ",
- "copiedAll": "ሁሉም ተቀድቷል"
+ "copiedAll": "ሁሉም ተቀድቷል",
+ "payloadSizeLimitOmitted": "ጭነት ተትቷል — ይህ ክፍል የጥሪ ምዝግብ መጠን ገደብን (CALL_LOG_PIPELINE_MAX_SIZE_KB) አልፏል እና አልተቀመጠም፤ እውነተኛ የላይኛው ስህተት አይደለም።"
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 3f0f02f257..b91d4b0890 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -11411,6 +11411,7 @@
"clientResponse": "استجابة العميل",
"pipelineError": "خطأ في خط الأنابيب"
},
+ "payloadSizeLimitOmitted": "تم حذف الحمولة — تجاوز هذا القسم حد حجم سجل الاستدعاءات (CALL_LOG_PIPELINE_MAX_SIZE_KB) ولم يتم تخزينه؛ هذا ليس خطأً حقيقيًا من المزوّد.",
"payloadMissing": "لا يتوفر حِمل البيانات التفصيلي بعد الآن لهذه السجل.",
"payloadCorrupt": "تعذر تحليل الحمولة التفصيلية.",
"notAvailable": "غير متوفر",
diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json
index 5f1588a81f..4f76783c38 100644
--- a/src/i18n/messages/az.json
+++ b/src/i18n/messages/az.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Müştəri Cavabı",
"pipelineError": "Borular Xətası"
},
+ "payloadSizeLimitOmitted": "Yük buraxıldı — bu bölmə çağırış jurnalının ölçü limitini (CALL_LOG_PIPELINE_MAX_SIZE_KB) aşdı və saxlanılmadı; bu, real yuxarı axın xətası deyil.",
"payloadMissing": "Bu log girişinə aid ətraflı yük artefaktı artıq mövcud deyil.",
"payloadCorrupt": "Ətraflı yük artefaktı təhlil oluna bilmədi.",
"notAvailable": "Mövcud deyil",
diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json
index f5f0740e90..fb58a93d13 100644
--- a/src/i18n/messages/bg.json
+++ b/src/i18n/messages/bg.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Отговор на клиента",
"pipelineError": "Грешка в потока"
},
+ "payloadSizeLimitOmitted": "Съдържанието е пропуснато — тази секция надвиши лимита за размер на дневника на заявките (CALL_LOG_PIPELINE_MAX_SIZE_KB) и не беше записана; това не е реална грешка от доставчика.",
"payloadMissing": "Подробният артефакт на полезния товар вече не е наличен за този запис на лог.",
"payloadCorrupt": "Неуспешно парсиране на детайлен артефакт на полезния товар.",
"notAvailable": "Няма данни",
diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json
index 378738b366..3cc50edbb1 100644
--- a/src/i18n/messages/bn.json
+++ b/src/i18n/messages/bn.json
@@ -11411,6 +11411,7 @@
"clientResponse": "ক্লায়েন্ট প্রতিক্রিয়া",
"pipelineError": "পাইপলাইন ত্রুটি"
},
+ "payloadSizeLimitOmitted": "পেলোড বাদ দেওয়া হয়েছে — এই অংশটি কল লগের আকারসীমা (CALL_LOG_PIPELINE_MAX_SIZE_KB) ছাড়িয়ে গেছে এবং সংরক্ষণ করা হয়নি; এটি প্রকৃত আপস্ট্রিম ত্রুটি নয়।",
"payloadMissing": "এই লগ এন্ট্রির জন্য বিস্তারিত পেলোড আর্টিফ্যাক্ট আর উপলব্ধ নেই।",
"payloadCorrupt": "বিস্তারিত পেলোড আর্টিফ্যাক্ট পার্স করা যায়নি।",
"notAvailable": "প্রযোজ্য নয়",
diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json
index 3168a9838c..fb2ac9b876 100644
--- a/src/i18n/messages/cs.json
+++ b/src/i18n/messages/cs.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Odpověď klienta",
"pipelineError": "Chyba v pipeline"
},
+ "payloadSizeLimitOmitted": "Obsah vynechán — tato sekce překročila limit velikosti záznamu volání (CALL_LOG_PIPELINE_MAX_SIZE_KB) a nebyla uložena; nejde o skutečnou chybu poskytovatele.",
"payloadMissing": "Podrobný artefakt payloadu již není k dispozici pro tento záznam protokolu.",
"payloadCorrupt": "Podrobný payload artefakt nebyl možné analyzovat.",
"notAvailable": "Není k dispozici",
diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json
index de39f2ea2e..e24cd8e168 100644
--- a/src/i18n/messages/da.json
+++ b/src/i18n/messages/da.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Klientrespons",
"pipelineError": "Pipeline Fejl"
},
+ "payloadSizeLimitOmitted": "Payload udeladt — denne sektion overskred størrelsesgrænsen for kaldloggen (CALL_LOG_PIPELINE_MAX_SIZE_KB) og blev ikke gemt; det er ikke en reel upstream-fejl.",
"payloadMissing": "Den detaljerede payload-artifact er ikke længere tilgængelig for denne logpost.",
"payloadCorrupt": "Den detaljerede payload-artifact kunne ikke parses.",
"notAvailable": "Ikke tilgængelig",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 7bd41db6da..4d3a945fca 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Kundenantwort",
"pipelineError": "Pipeline-Fehler"
},
+ "payloadSizeLimitOmitted": "Payload ausgelassen — dieser Abschnitt hat das Größenlimit des Aufrufprotokolls (CALL_LOG_PIPELINE_MAX_SIZE_KB) überschritten und wurde nicht gespeichert; es handelt sich nicht um einen echten Upstream-Fehler.",
"payloadMissing": "Das detaillierte Payload-Artefakt ist für diesen Protokolleintrag nicht mehr verfügbar.",
"payloadCorrupt": "Detailliertes Payload-Artefakt konnte nicht analysiert werden.",
"notAvailable": "Nicht verfügbar",
diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json
index 495ae6eaf8..4d367cb94e 100644
--- a/src/i18n/messages/el.json
+++ b/src/i18n/messages/el.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "Αναγνωριστικό συσχέτισης: {id}",
"detailedPayloadInfo": "Ενεργοποιήστε πρώτα τη λεπτομερή καταγραφή εάν θέλετε την προβολή ωφέλιμου φορτίου τεσσάρων σταδίων πελάτη/παρόχου για νέα αιτήματα.",
"copyAll": "Αντιγραφή όλων",
- "copiedAll": "Αντιγράφηκαν όλα"
+ "copiedAll": "Αντιγράφηκαν όλα",
+ "payloadSizeLimitOmitted": "Το φορτίο παραλείφθηκε — αυτή η ενότητα υπέρβη το όριο μεγέθους του αρχείου κλήσεων (CALL_LOG_PIPELINE_MAX_SIZE_KB) και δεν αποθηκεύτηκε· δεν πρόκειται για πραγματικό σφάλμα του παρόχου."
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 18433ec10a..a5b7e344e1 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -11418,6 +11418,7 @@
"clientResponse": "Client Response",
"pipelineError": "Pipeline Error"
},
+ "payloadSizeLimitOmitted": "Payload omitted — this section exceeded the call log size limit (CALL_LOG_PIPELINE_MAX_SIZE_KB) and was not stored, not a real upstream error.",
"payloadMissing": "Detailed payload artifact is no longer available for this log entry.",
"payloadCorrupt": "Detailed payload artifact could not be parsed.",
"notAvailable": "N/A",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index c994343113..b42c6ee410 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Respuesta del Cliente",
"pipelineError": "Error de Pipeline"
},
+ "payloadSizeLimitOmitted": "Contenido omitido — esta sección superó el límite de tamaño del registro de llamadas (CALL_LOG_PIPELINE_MAX_SIZE_KB) y no se guardó; no es un error real del proveedor.",
"payloadMissing": "El artefacto de carga detallada ya no está disponible para esta entrada de registro.",
"payloadCorrupt": "No se pudo analizar el artefacto de carga detallada.",
"notAvailable": "N/D",
diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json
index 2c707d5a7f..ffef4d6957 100644
--- a/src/i18n/messages/et.json
+++ b/src/i18n/messages/et.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Kliendi vastus",
"pipelineError": "Töötluskonveieri viga"
},
+ "payloadSizeLimitOmitted": "Sisu jäeti välja — see jaotis ületas kõnelogi suurusepiirangu (CALL_LOG_PIPELINE_MAX_SIZE_KB) ja seda ei salvestatud; tegemist ei ole tegeliku ülesvoolu veaga.",
"payloadMissing": "Selle logikirje üksikasjaliku andmekoormuse artefakt pole enam saadaval.",
"payloadCorrupt": "Üksikasjaliku andmekoormuse artefakti ei saanud sõeluda.",
"notAvailable": "Pole kohaldatav",
diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json
index 05851eebf8..96661041d3 100644
--- a/src/i18n/messages/fa.json
+++ b/src/i18n/messages/fa.json
@@ -11411,6 +11411,7 @@
"clientResponse": "پاسخ مشتری",
"pipelineError": "خطای پایپلاین"
},
+ "payloadSizeLimitOmitted": "محتوا حذف شد — این بخش از محدودیت اندازهٔ گزارش فراخوانی (CALL_LOG_PIPELINE_MAX_SIZE_KB) فراتر رفت و ذخیره نشد؛ این یک خطای واقعی از سمت ارائهدهنده نیست.",
"payloadMissing": "آرتیفکت بارگذاری دقیق دیگر برای این ورودی لاگ در دسترس نیست.",
"payloadCorrupt": "بارگذاری جزئیات بارگذاری نمیتواند تجزیه شود.",
"notAvailable": "غیر قابل استفاده",
diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json
index 8481082f86..be5a2d51bb 100644
--- a/src/i18n/messages/fi.json
+++ b/src/i18n/messages/fi.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Asiakkaan Vastaus",
"pipelineError": "Putkivirhe"
},
+ "payloadSizeLimitOmitted": "Sisältö jätetty pois — tämä osio ylitti kutsulokin kokorajan (CALL_LOG_PIPELINE_MAX_SIZE_KB) eikä sitä tallennettu; kyseessä ei ole todellinen upstream-virhe.",
"payloadMissing": "Yksityiskohtainen kuormitusartefakti ei ole enää saatavilla tälle lokimerkinnälle.",
"payloadCorrupt": "Yksityiskohtaisia kuormitusartefakteja ei voitu jäsentää.",
"notAvailable": "Ei saatavilla",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 2a6c4f708a..1cd48ab3a6 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Réponse du client",
"pipelineError": "Erreur du pipeline"
},
+ "payloadSizeLimitOmitted": "Contenu omis — cette section a dépassé la limite de taille du journal d'appels (CALL_LOG_PIPELINE_MAX_SIZE_KB) et n'a pas été enregistrée ; ce n'est pas une véritable erreur du fournisseur.",
"payloadMissing": "L’artefact de charge utile détaillé n’est plus disponible pour cette entrée de journal.",
"payloadCorrupt": "L’artefact de charge utile détaillé n’a pas pu être analysé.",
"notAvailable": "N/D",
diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json
index 8cd9bea706..4b502b7a1b 100644
--- a/src/i18n/messages/ga.json
+++ b/src/i18n/messages/ga.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "Aitheantas Comhghaoil: {id}",
"detailedPayloadInfo": "Cumasaigh logáil mhionsonraithe ar dtús más mian leat an t-amharc ceithre chéim ar ualaí cliaint/soláthraí le haghaidh iarratais nua.",
"copyAll": "Cóipeáil go léir",
- "copiedAll": "Cóipeáilte go léir"
+ "copiedAll": "Cóipeáilte go léir",
+ "payloadSizeLimitOmitted": "Fágadh an pálasta ar lár — sháraigh an rannán seo teorainn mhéid an logchomhaid glaonna (CALL_LOG_PIPELINE_MAX_SIZE_KB) agus níor stóráladh é; ní fíorearráid ón soláthraí é seo."
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json
index 9ff6a83a56..161bf7ce3b 100644
--- a/src/i18n/messages/gu.json
+++ b/src/i18n/messages/gu.json
@@ -11411,6 +11411,7 @@
"clientResponse": "ક્લાયન્ટ પ્રતિસાદ",
"pipelineError": "પાઇપલાઇન ભૂલ"
},
+ "payloadSizeLimitOmitted": "પેલોડ છોડી દેવાયો — આ વિભાગ કૉલ લૉગની કદ મર્યાદા (CALL_LOG_PIPELINE_MAX_SIZE_KB) ઓળંગી ગયો અને સંગ્રહાયો નથી; આ વાસ્તવિક અપસ્ટ્રીમ ભૂલ નથી.",
"payloadMissing": "આ લોગ એન્ટ્રી માટે વિગતવાર પેઇલોડ આર્ટિફેક્ટ હવે ઉપલબ્ધ નથી.",
"payloadCorrupt": "વિસ્તૃત પેલોડ આર્ટિફેક્ટને પાર્સ કરી શકાયું નથી.",
"notAvailable": "લાગુ પડતું નથી",
diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json
index d61386288c..92324a9147 100644
--- a/src/i18n/messages/ha.json
+++ b/src/i18n/messages/ha.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "ID na Alaƙa: {id}",
"detailedPayloadInfo": "Da farko, kunna yin cikakken log idan kana son ganin payload na matakai huɗu na abokin ciniki/mai bayarwa don sabbin buƙatu.",
"copyAll": "Kwafi duka",
- "copiedAll": "An kwafi duka"
+ "copiedAll": "An kwafi duka",
+ "payloadSizeLimitOmitted": "An bar abin da aka aika — wannan sashe ya wuce iyakar girman rajistar kira (CALL_LOG_PIPELINE_MAX_SIZE_KB) kuma ba a adana shi ba; wannan ba ainihin kuskuren mai bayarwa ba ne."
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json
index b52c5d1d7a..54fa106d02 100644
--- a/src/i18n/messages/he.json
+++ b/src/i18n/messages/he.json
@@ -11411,6 +11411,7 @@
"clientResponse": "תגובה מהלקוח",
"pipelineError": "שגיאת צינור"
},
+ "payloadSizeLimitOmitted": "התוכן הושמט — מקטע זה חרג ממגבלת הגודל של יומן הקריאות (CALL_LOG_PIPELINE_MAX_SIZE_KB) ולא נשמר; אין מדובר בשגיאה אמיתית מהספק.",
"payloadMissing": "פרטי העמסה מפורטים אינם זמינים יותר עבור רשומת הלוג הזו.",
"payloadCorrupt": "לא ניתן לנתח את הארטיפקט של העומס המפורט.",
"notAvailable": "לא זמין",
diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json
index 1b109bf450..4751f721c9 100644
--- a/src/i18n/messages/hi.json
+++ b/src/i18n/messages/hi.json
@@ -11411,6 +11411,7 @@
"clientResponse": "क्लाइंट प्रतिक्रिया",
"pipelineError": "पाइपलाइन त्रुटि"
},
+ "payloadSizeLimitOmitted": "पेलोड छोड़ दिया गया — यह अनुभाग कॉल लॉग की आकार सीमा (CALL_LOG_PIPELINE_MAX_SIZE_KB) से अधिक हो गया और संग्रहीत नहीं किया गया; यह कोई वास्तविक अपस्ट्रीम त्रुटि नहीं है।",
"payloadMissing": "इस लॉग प्रविष्टि के लिए विस्तृत पेलोड आर्टिफैक्ट अब उपलब्ध नहीं है।",
"payloadCorrupt": "विस्तृत पेलोड आर्टिफैक्ट को पार्स नहीं किया जा सका।",
"notAvailable": "लागू नहीं",
diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json
index 74fdd5c2d6..898a38a146 100644
--- a/src/i18n/messages/hr.json
+++ b/src/i18n/messages/hr.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Odgovor klijenta",
"pipelineError": "Greška cjevovoda"
},
+ "payloadSizeLimitOmitted": "Sadržaj izostavljen — ovaj odjeljak premašio je ograničenje veličine zapisnika poziva (CALL_LOG_PIPELINE_MAX_SIZE_KB) i nije pohranjen; nije riječ o stvarnoj pogrešci pružatelja.",
"payloadMissing": "Detaljan artefakt sadržaja više nije dostupan za ovaj unos zapisa.",
"payloadCorrupt": "Detaljan artefakt sadržaja nije bilo moguće raščlaniti.",
"notAvailable": "Nije primjenjivo",
diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json
index 53d40f95e2..a5d4c4f116 100644
--- a/src/i18n/messages/hu.json
+++ b/src/i18n/messages/hu.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Ügyfél Válasz",
"pipelineError": "Pipeline Hiba"
},
+ "payloadSizeLimitOmitted": "Tartalom kihagyva — ez a szakasz túllépte a hívásnapló méretkorlátját (CALL_LOG_PIPELINE_MAX_SIZE_KB), ezért nem lett elmentve; ez nem valódi upstream hiba.",
"payloadMissing": "A részletes payload artefaktum már nem elérhető ehhez a naplóbejegyzéshez.",
"payloadCorrupt": "A részletes payload artefaktumot nem sikerült elemezni.",
"notAvailable": "N/A",
diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json
index 8145760cc1..8b0fe9f2d4 100644
--- a/src/i18n/messages/hy.json
+++ b/src/i18n/messages/hy.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "Կապակցման ID՝ {id}",
"detailedPayloadInfo": "Նոր հարցումների համար հաճախորդի/մատակարարի օգտակար բեռների քառափուլ տեսքը դիտելու նպատակով նախ միացրեք մանրամասն գրանցումը։",
"copyAll": "Պատճենել ամբողջը",
- "copiedAll": "Ամբողջը պատճենված է"
+ "copiedAll": "Ամբողջը պատճենված է",
+ "payloadSizeLimitOmitted": "Բովանդակությունը բաց է թողնվել — այս բաժինը գերազանցել է կանչերի մատյանի չափի սահմանը (CALL_LOG_PIPELINE_MAX_SIZE_KB) և չի պահպանվել. սա իրական վերին հոսքի սխալ չէ։"
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json
index e10e9c2f16..90d2a60902 100644
--- a/src/i18n/messages/id.json
+++ b/src/i18n/messages/id.json
@@ -11414,6 +11414,7 @@
"clientResponse": "Tanggapan Klien",
"pipelineError": "Kesalahan Pipeline"
},
+ "payloadSizeLimitOmitted": "Payload dihilangkan — bagian ini melampaui batas ukuran log panggilan (CALL_LOG_PIPELINE_MAX_SIZE_KB) dan tidak disimpan; ini bukan kesalahan upstream yang sebenarnya.",
"payloadMissing": "Artifact payload yang terperinci tidak lagi tersedia untuk entri log ini.",
"payloadCorrupt": "Artifact payload yang rinci tidak dapat diparsing.",
"notAvailable": "T/A",
diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json
index 1b389cf312..23b85cde89 100644
--- a/src/i18n/messages/ig.json
+++ b/src/i18n/messages/ig.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Nzaghachi Onye Ahịa",
"pipelineError": "Njehie Pipeline"
},
+ "payloadSizeLimitOmitted": "Ewepụrụ ihe ezigara — akụkụ a gafere oke nha ndekọ oku (CALL_LOG_PIPELINE_MAX_SIZE_KB), a chekwaghịkwa ya; ọ bụghị ezigbo njehie sitere n'aka onye na-enye ọrụ.",
"payloadMissing": "Nkọwa payload zuru ezu adịkwaghị maka ndekọ a.",
"payloadCorrupt": "Enweghị ike ịgụ ma nyochaa payload ahụ nwere nkọwa zuru ezu.",
"notAvailable": "Ọ dịghị",
diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json
index d0a6f84f35..324ff86137 100644
--- a/src/i18n/messages/it.json
+++ b/src/i18n/messages/it.json
@@ -11415,6 +11415,7 @@
"clientResponse": "Risposta del Cliente",
"pipelineError": "Errore della Pipeline"
},
+ "payloadSizeLimitOmitted": "Contenuto omesso — questa sezione ha superato il limite di dimensione del registro delle chiamate (CALL_LOG_PIPELINE_MAX_SIZE_KB) e non è stata salvata; non si tratta di un vero errore del provider.",
"payloadMissing": "L'articolo del payload dettagliato non è più disponibile per questa voce di log.",
"payloadCorrupt": "L'artifact del payload dettagliato non può essere analizzato.",
"notAvailable": "N/D",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 5d55b1a124..d7fca9b640 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -11411,6 +11411,7 @@
"clientResponse": "クライアントの応答",
"pipelineError": "パイプラインエラー"
},
+ "payloadSizeLimitOmitted": "ペイロードは省略されました — このセクションは呼び出しログのサイズ上限(CALL_LOG_PIPELINE_MAX_SIZE_KB)を超えたため保存されませんでした。実際のアップストリームエラーではありません。",
"payloadMissing": "このログエントリの詳細なペイロードアーティファクトはもはや利用できません。",
"payloadCorrupt": "詳細なペイロードアーティファクトを解析できませんでした。",
"notAvailable": "該当なし",
diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json
index 7972173aae..f7cfbcd030 100644
--- a/src/i18n/messages/ka.json
+++ b/src/i18n/messages/ka.json
@@ -11411,6 +11411,7 @@
"clientResponse": "კლიენტის პასუხი",
"pipelineError": "კონვეიერის შეცდომა"
},
+ "payloadSizeLimitOmitted": "შიგთავსი გამოტოვებულია — ამ განყოფილებამ გადააჭარბა გამოძახებების ჟურნალის ზომის ლიმიტს (CALL_LOG_PIPELINE_MAX_SIZE_KB) და არ შეინახა; ეს არ არის პროვაიდერის რეალური შეცდომა.",
"payloadMissing": "ამ ჟურნალის ჩანაწერის დეტალური სასარგებლო მონაცემების არტეფაქტი აღარ არის ხელმისაწვდომი.",
"payloadCorrupt": "დეტალური სასარგებლო მონაცემების არტეფაქტის გარჩევა ვერ მოხერხდა.",
"notAvailable": "არ გამოიყენება",
diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json
index 42aced97d5..1496aaec82 100644
--- a/src/i18n/messages/km.json
+++ b/src/i18n/messages/km.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "លេខសម្គាល់ទំនាក់ទំនង៖ {id}",
"detailedPayloadInfo": "សូមបើកការកត់ត្រាលម្អិតជាមុនសិន ប្រសិនបើអ្នកចង់មើល payload បួនដំណាក់កាលរបស់កម្មវិធីអតិថិជន/អ្នកផ្តល់សេវា សម្រាប់សំណើថ្មីៗ។",
"copyAll": "ចម្លងទាំងអស់",
- "copiedAll": "បានចម្លងទាំងអស់"
+ "copiedAll": "បានចម្លងទាំងអស់",
+ "payloadSizeLimitOmitted": "បានលុបខ្លឹមសារ — ផ្នែកនេះលើសដែនកំណត់ទំហំកំណត់ហេតុការហៅ (CALL_LOG_PIPELINE_MAX_SIZE_KB) ហើយមិនត្រូវបានរក្សាទុកទេ។ នេះមិនមែនជាកំហុសពិតពីអ្នកផ្តល់សេវាទេ។"
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json
index 2a076d431b..205492b0be 100644
--- a/src/i18n/messages/kn.json
+++ b/src/i18n/messages/kn.json
@@ -11411,6 +11411,7 @@
"clientResponse": "ಕ್ಲೈಂಟ್ ಪ್ರತಿಕ್ರಿಯೆ",
"pipelineError": "ಪೈಪ್ಲೈನ್ ದೋಷ"
},
+ "payloadSizeLimitOmitted": "ಪೇಲೋಡ್ ಬಿಟ್ಟುಬಿಡಲಾಗಿದೆ — ಈ ವಿಭಾಗವು ಕರೆ ಲಾಗ್ ಗಾತ್ರ ಮಿತಿಯನ್ನು (CALL_LOG_PIPELINE_MAX_SIZE_KB) ಮೀರಿದೆ ಮತ್ತು ಸಂಗ್ರಹಿಸಲಾಗಿಲ್ಲ; ಇದು ನಿಜವಾದ ಅಪ್ಸ್ಟ್ರೀಮ್ ದೋಷವಲ್ಲ.",
"payloadMissing": "ಈ ಲಾಗ್ ನಮೂದಿಗೆ ವಿವರವಾದ ಪೇಲೋಡ್ ಆರ್ಟಿಫ್ಯಾಕ್ಟ್ ಇನ್ನು ಮುಂದೆ ಲಭ್ಯವಿಲ್ಲ.",
"payloadCorrupt": "ವಿವರವಾದ ಪೇಲೋಡ್ ಆರ್ಟಿಫ್ಯಾಕ್ಟ್ ಅನ್ನು ಪಾರ್ಸ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ.",
"notAvailable": "ಅನ್ವಯಿಸುವುದಿಲ್ಲ",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 89b351f5be..7bab3beb2f 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -11411,6 +11411,7 @@
"clientResponse": "클라이언트 응답",
"pipelineError": "파이프라인 오류"
},
+ "payloadSizeLimitOmitted": "페이로드가 생략되었습니다 — 이 섹션은 호출 로그 크기 제한(CALL_LOG_PIPELINE_MAX_SIZE_KB)을 초과하여 저장되지 않았습니다. 실제 업스트림 오류가 아닙니다.",
"payloadMissing": "이 로그 항목에 대한 상세 페이로드 아티팩트가 더 이상 사용 가능하지 않습니다.",
"payloadCorrupt": "상세 페이로드 아티팩트를 구문 분석할 수 없습니다.",
"notAvailable": "해당 없음",
diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json
index 8ea3176e6d..11352f7bb1 100644
--- a/src/i18n/messages/lt.json
+++ b/src/i18n/messages/lt.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Kliento atsakymas",
"pipelineError": "Konvejerio klaida"
},
+ "payloadSizeLimitOmitted": "Turinys praleistas — ši dalis viršijo iškvietimų žurnalo dydžio limitą (CALL_LOG_PIPELINE_MAX_SIZE_KB) ir nebuvo išsaugota; tai nėra tikra tiekėjo klaida.",
"payloadMissing": "Išsamus šio žurnalo įrašo duomenų artefaktas nebepasiekiamas.",
"payloadCorrupt": "Nepavyko išanalizuoti išsamaus duomenų artefakto.",
"notAvailable": "Nėra duomenų",
diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json
index c14afdf389..38312cc206 100644
--- a/src/i18n/messages/lv.json
+++ b/src/i18n/messages/lv.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "Korelācijas ID: {id}",
"detailedPayloadInfo": "Vispirms iespējojiet detalizētu žurnalēšanu, ja vēlaties četru posmu klienta/nodrošinātāja derīgās slodzes skatu jauniem pieprasījumiem.",
"copyAll": "Kopēt visu",
- "copiedAll": "Viss nokopēts"
+ "copiedAll": "Viss nokopēts",
+ "payloadSizeLimitOmitted": "Saturs izlaists — šī sadaļa pārsniedza izsaukumu žurnāla izmēra ierobežojumu (CALL_LOG_PIPELINE_MAX_SIZE_KB) un netika saglabāta; tā nav īsta pakalpojumu sniedzēja kļūda."
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json
index 71062c5c59..77dc791e9b 100644
--- a/src/i18n/messages/ml.json
+++ b/src/i18n/messages/ml.json
@@ -11411,6 +11411,7 @@
"clientResponse": "ക്ലയന്റ് പ്രതികരണം",
"pipelineError": "പൈപ്പ്ലൈൻ പിശക്"
},
+ "payloadSizeLimitOmitted": "പേലോഡ് ഒഴിവാക്കി — ഈ വിഭാഗം കോൾ ലോഗ് വലുപ്പ പരിധി (CALL_LOG_PIPELINE_MAX_SIZE_KB) കവിഞ്ഞതിനാൽ സംഭരിച്ചിട്ടില്ല; ഇത് യഥാർത്ഥ അപ്സ്ട്രീം പിശകല്ല.",
"payloadMissing": "ഈ ലോഗ് എൻട്രിക്കായുള്ള വിശദമായ പേലോഡ് ആർട്ടിഫാക്റ്റ് ഇനി ലഭ്യമല്ല.",
"payloadCorrupt": "വിശദമായ പേലോഡ് ആർട്ടിഫാക്റ്റ് പാഴ്സ് ചെയ്യാനായില്ല.",
"notAvailable": "ലഭ്യമല്ല",
diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json
index ef78ec0db1..9006718762 100644
--- a/src/i18n/messages/mr.json
+++ b/src/i18n/messages/mr.json
@@ -11411,6 +11411,7 @@
"clientResponse": "ग्राहक प्रतिसाद",
"pipelineError": "पाइपलाइन त्रुटी"
},
+ "payloadSizeLimitOmitted": "पेलोड वगळला — हा विभाग कॉल लॉगच्या आकार मर्यादेपेक्षा (CALL_LOG_PIPELINE_MAX_SIZE_KB) मोठा झाला आणि संग्रहित केला गेला नाही; ही खरी अपस्ट्रीम त्रुटी नाही.",
"payloadMissing": "या लॉग नोंदीसाठी तपशीलवार पेलोड आर्टिफॅक्ट आता उपलब्ध नाही.",
"payloadCorrupt": "तपशीलवार पेलोड आर्टिफॅक्ट पार्स केला जाऊ शकला नाही.",
"notAvailable": "लागू नाही",
diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json
index e5dab503f5..4a7ca0a34b 100644
--- a/src/i18n/messages/ms.json
+++ b/src/i18n/messages/ms.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Tanggapan Klien",
"pipelineError": "Ralat Saluran"
},
+ "payloadSizeLimitOmitted": "Muatan diabaikan — bahagian ini melebihi had saiz log panggilan (CALL_LOG_PIPELINE_MAX_SIZE_KB) dan tidak disimpan; ini bukan ralat huluan yang sebenar.",
"payloadMissing": "Artifak payload terperinci tidak lagi tersedia untuk entri log ini.",
"payloadCorrupt": "Artifak payload terperinci tidak dapat dianalisis.",
"notAvailable": "T/B",
diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json
index ba922fe89b..281c74597d 100644
--- a/src/i18n/messages/mt.json
+++ b/src/i18n/messages/mt.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Risposta lill-klijent",
"pipelineError": "Żball fil-pipeline"
},
+ "payloadSizeLimitOmitted": "Il-kontenut tħalla barra — din it-taqsima qabżet il-limitu tad-daqs tar-reġistru tas-sejħiet (CALL_LOG_PIPELINE_MAX_SIZE_KB) u ma nħażnitx; dan mhuwiex żball reali tal-fornitur.",
"payloadMissing": "L-artifatt dettaljat tal-payload m’għadux disponibbli għal din l-entrata tal-log.",
"payloadCorrupt": "L-artifatt dettaljat tal-payload ma setax jiġi analizzat.",
"notAvailable": "Mhux applikabbli",
diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json
index 47d87291bd..fbb996d509 100644
--- a/src/i18n/messages/my.json
+++ b/src/i18n/messages/my.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Client တုံ့ပြန်ချက်",
"pipelineError": "Pipeline အမှား"
},
+ "payloadSizeLimitOmitted": "ပေးပို့ဒေတာကို ချန်လှပ်ထားသည် — ဤအပိုင်းသည် ခေါ်ဆိုမှုမှတ်တမ်း အရွယ်အစားကန့်သတ်ချက် (CALL_LOG_PIPELINE_MAX_SIZE_KB) ကို ကျော်လွန်သဖြင့် သိမ်းဆည်းမထားပါ။ ဤသည်မှာ အထက်စီးကြောင်းမှ အမှန်တကယ်အမှားမဟုတ်ပါ။",
"payloadMissing": "ဤမှတ်တမ်းအတွက် အသေးစိတ် payload artifact ကို မရနိုင်တော့ပါ။",
"payloadCorrupt": "အသေးစိတ် payload artifact ကို ခွဲခြမ်းဖတ်ရှု၍ မရပါ။",
"notAvailable": "မသက်ဆိုင်ပါ",
diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json
index 89f527cbf7..4c2f27c1c7 100644
--- a/src/i18n/messages/ne.json
+++ b/src/i18n/messages/ne.json
@@ -11411,6 +11411,7 @@
"clientResponse": "क्लाइन्ट प्रतिक्रिया",
"pipelineError": "पाइपलाइन त्रुटि"
},
+ "payloadSizeLimitOmitted": "पेलोड छोडियो — यो खण्डले कल लगको आकार सीमा (CALL_LOG_PIPELINE_MAX_SIZE_KB) नाघ्यो र भण्डारण गरिएन; यो वास्तविक अपस्ट्रिम त्रुटि होइन।",
"payloadMissing": "यो लग प्रविष्टिका लागि विस्तृत पेलोड आर्टिफ्याक्ट अब उपलब्ध छैन।",
"payloadCorrupt": "विस्तृत पेलोड आर्टिफ्याक्ट पार्स गर्न सकिएन।",
"notAvailable": "लागू हुँदैन",
diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json
index d99866011d..a174af5554 100644
--- a/src/i18n/messages/nl.json
+++ b/src/i18n/messages/nl.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Klantreactie",
"pipelineError": "Pijplijnfout"
},
+ "payloadSizeLimitOmitted": "Payload weggelaten — deze sectie overschreed de groottelimiet van het aanroeplogboek (CALL_LOG_PIPELINE_MAX_SIZE_KB) en is niet opgeslagen; dit is geen echte upstream-fout.",
"payloadMissing": "Gedetailleerde payload-artifact is niet langer beschikbaar voor deze logvermelding.",
"payloadCorrupt": "Gedetailleerde payload-artifact kon niet worden geparsed.",
"notAvailable": "N.v.t.",
diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json
index b07e9b3cc8..1fba7fca2d 100644
--- a/src/i18n/messages/no.json
+++ b/src/i18n/messages/no.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Klientrespons",
"pipelineError": "Pipeline-feil"
},
+ "payloadSizeLimitOmitted": "Innhold utelatt — denne seksjonen overskred størrelsesgrensen for kalloggen (CALL_LOG_PIPELINE_MAX_SIZE_KB) og ble ikke lagret; dette er ikke en reell upstream-feil.",
"payloadMissing": "Detaljert nyttelastartefakt er ikke lenger tilgjengelig for denne loggoppføringen.",
"payloadCorrupt": "Detaljert nyttelastartefakt kunne ikke bli analysert.",
"notAvailable": "Ikke tilgjengelig",
diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json
index 47d74d8b03..8338f6db16 100644
--- a/src/i18n/messages/or.json
+++ b/src/i18n/messages/or.json
@@ -11411,6 +11411,7 @@
"clientResponse": "କ୍ଲାଏଣ୍ଟ ପ୍ରତିକ୍ରିୟା",
"pipelineError": "ପାଇପ୍ଲାଇନ୍ ତ୍ରୁଟି"
},
+ "payloadSizeLimitOmitted": "ପେଲୋଡ୍ ଛାଡ଼ି ଦିଆଯାଇଛି — ଏହି ବିଭାଗ କଲ୍ ଲଗ୍ ଆକାର ସୀମା (CALL_LOG_PIPELINE_MAX_SIZE_KB) ଅତିକ୍ରମ କରିଛି ଏବଂ ସଂରକ୍ଷିତ ହୋଇନାହିଁ; ଏହା ପ୍ରକୃତ ଅପଷ୍ଟ୍ରିମ୍ ତ୍ରୁଟି ନୁହେଁ।",
"payloadMissing": "ଏହି ଲଗ୍ ଏଣ୍ଟ୍ରି ପାଇଁ ବିସ୍ତୃତ ପେଲୋଡ୍ ଆର୍ଟିଫ୍ୟାକ୍ଟ ଆଉ ଉପଲବ୍ଧ ନାହିଁ।",
"payloadCorrupt": "ବିସ୍ତୃତ ପେଲୋଡ୍ ଆର୍ଟିଫ୍ୟାକ୍ଟକୁ ପାର୍ସ କରାଯାଇପାରିଲା ନାହିଁ।",
"notAvailable": "ପ୍ରଯୁଜ୍ୟ ନୁହେଁ",
diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json
index 3bc1974ba4..1b4e809373 100644
--- a/src/i18n/messages/pa.json
+++ b/src/i18n/messages/pa.json
@@ -11411,6 +11411,7 @@
"clientResponse": "ਕਲਾਇੰਟ ਜਵਾਬ",
"pipelineError": "ਪਾਈਪਲਾਈਨ ਗਲਤੀ"
},
+ "payloadSizeLimitOmitted": "ਪੇਲੋਡ ਛੱਡ ਦਿੱਤਾ ਗਿਆ — ਇਹ ਭਾਗ ਕਾਲ ਲੌਗ ਦੀ ਆਕਾਰ ਸੀਮਾ (CALL_LOG_PIPELINE_MAX_SIZE_KB) ਤੋਂ ਵੱਧ ਗਿਆ ਅਤੇ ਸਟੋਰ ਨਹੀਂ ਕੀਤਾ ਗਿਆ; ਇਹ ਅਸਲ ਅੱਪਸਟ੍ਰੀਮ ਗਲਤੀ ਨਹੀਂ ਹੈ।",
"payloadMissing": "ਇਸ ਲੌਗ ਐਂਟਰੀ ਲਈ ਵਿਸਤ੍ਰਿਤ ਪੇਲੋਡ ਆਰਟੀਫੈਕਟ ਹੁਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ।",
"payloadCorrupt": "ਵਿਸਤ੍ਰਿਤ ਪੇਲੋਡ ਆਰਟੀਫੈਕਟ ਨੂੰ ਪਾਰਸ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।",
"notAvailable": "ਲਾਗੂ ਨਹੀਂ",
diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json
index e027549c9f..906dd77768 100644
--- a/src/i18n/messages/phi.json
+++ b/src/i18n/messages/phi.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Tugon ng Kliyente",
"pipelineError": "Error sa Pipeline"
},
+ "payloadSizeLimitOmitted": "Inalis ang payload — lumampas ang seksyong ito sa limitasyon ng laki ng call log (CALL_LOG_PIPELINE_MAX_SIZE_KB) at hindi na-save; hindi ito tunay na upstream error.",
"payloadMissing": "Ang detalyadong payload artifact ay hindi na available para sa log entry na ito.",
"payloadCorrupt": "Hindi ma-parse ang detalyadong payload artifact.",
"notAvailable": "Hindi naaangkop",
diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json
index 74cd55d13c..4d6bf333c2 100644
--- a/src/i18n/messages/pl.json
+++ b/src/i18n/messages/pl.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Odpowiedź Klienta",
"pipelineError": "Błąd potoku"
},
+ "payloadSizeLimitOmitted": "Zawartość pominięta — ta sekcja przekroczyła limit rozmiaru dziennika wywołań (CALL_LOG_PIPELINE_MAX_SIZE_KB) i nie została zapisana; to nie jest rzeczywisty błąd dostawcy.",
"payloadMissing": "Szczegółowy ładunek artefaktu nie jest już dostępny dla tego wpisu dziennika.",
"payloadCorrupt": "Szczegółowy ładunek artefaktu nie mógł zostać sparsowany.",
"notAvailable": "N/D",
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index 2d47bd31e1..3114c9ca26 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -11415,6 +11415,7 @@
"clientResponse": "Resposta do Cliente",
"pipelineError": "Erro de Pipeline"
},
+ "payloadSizeLimitOmitted": "Payload omitido — esta seção excedeu o limite de tamanho do log de chamadas (CALL_LOG_PIPELINE_MAX_SIZE_KB) e não foi armazenada; não é um erro real do upstream.",
"payloadMissing": "O artefato de carga detalhada não está mais disponível para esta entrada de log.",
"payloadCorrupt": "O artefato de carga detalhada não pôde ser analisado.",
"notAvailable": "N/D",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index d04fdc9f39..6093ca0ef3 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Resposta do Cliente",
"pipelineError": "Erro de Pipeline"
},
+ "payloadSizeLimitOmitted": "Conteúdo omitido — esta secção excedeu o limite de tamanho do registo de chamadas (CALL_LOG_PIPELINE_MAX_SIZE_KB) e não foi guardada; não se trata de um erro real do fornecedor.",
"payloadMissing": "O artefato de carga detalhada já não está disponível para esta entrada de log.",
"payloadCorrupt": "O artefato do payload detalhado não pôde ser analisado.",
"notAvailable": "N/D",
diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json
index f3e6c00b10..ebf5f746ff 100644
--- a/src/i18n/messages/ro.json
+++ b/src/i18n/messages/ro.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Răspunsul Clientului",
"pipelineError": "Eroare de Pipeline"
},
+ "payloadSizeLimitOmitted": "Conținut omis — această secțiune a depășit limita de dimensiune a jurnalului de apeluri (CALL_LOG_PIPELINE_MAX_SIZE_KB) și nu a fost stocată; nu este o eroare reală a furnizorului.",
"payloadMissing": "Artifactul detaliat al payload-ului nu mai este disponibil pentru această intrare de jurnal.",
"payloadCorrupt": "Artifactul detaliat al payload-ului nu a putut fi analizat.",
"notAvailable": "Indisponibil",
diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json
index d0409b2e5d..1f1b187e79 100644
--- a/src/i18n/messages/ru.json
+++ b/src/i18n/messages/ru.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Ответ клиента",
"pipelineError": "Ошибка конвейера"
},
+ "payloadSizeLimitOmitted": "Содержимое пропущено — этот раздел превысил ограничение размера журнала вызовов (CALL_LOG_PIPELINE_MAX_SIZE_KB) и не был сохранён; это не реальная ошибка провайдера.",
"payloadMissing": "Подробный артефакт полезной нагрузки больше недоступен для этой записи журнала.",
"payloadCorrupt": "Не удалось разобрать детализированный артефакт полезной нагрузки.",
"notAvailable": "Н/Д",
diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json
index 5f3d885b47..617ee6f718 100644
--- a/src/i18n/messages/si.json
+++ b/src/i18n/messages/si.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "සහසම්බන්ධතා හැඳුනුම්කාරකය: {id}",
"detailedPayloadInfo": "නව ඉල්ලීම් සඳහා අදියර හතරක සේවාලාභී/සපයන්නා දත්ත කොටස් දසුන අවශ්ය නම්, පළමුව සවිස්තරාත්මක ලොග් කිරීම සබල කරන්න.",
"copyAll": "සියල්ල පිටපත් කරන්න",
- "copiedAll": "සියල්ල පිටපත් කරන ලදී"
+ "copiedAll": "සියල්ල පිටපත් කරන ලදී",
+ "payloadSizeLimitOmitted": "අන්තර්ගතය මඟ හැරිණි — මෙම කොටස ඇමතුම් ලොග් ප්රමාණ සීමාව (CALL_LOG_PIPELINE_MAX_SIZE_KB) ඉක්මවා ගිය අතර ගබඩා නොකෙරිණි; මෙය සැබෑ සැපයුම්කරු දෝෂයක් නොවේ."
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json
index ebb5da0775..f4280c7127 100644
--- a/src/i18n/messages/sk.json
+++ b/src/i18n/messages/sk.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Odpoveď Klienta",
"pipelineError": "Chyba v pipeline"
},
+ "payloadSizeLimitOmitted": "Obsah vynechaný — táto sekcia prekročila limit veľkosti záznamu volaní (CALL_LOG_PIPELINE_MAX_SIZE_KB) a nebola uložená; nejde o skutočnú chybu poskytovateľa.",
"payloadMissing": "Podrobný payload artefakt už nie je k dispozícii pre tento záznam protokolu.",
"payloadCorrupt": "Podrobný payload artefakt sa nedal analyzovať.",
"notAvailable": "N/A",
diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json
index b0c00b74b6..a666be7b0f 100644
--- a/src/i18n/messages/sl.json
+++ b/src/i18n/messages/sl.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Odgovor odjemalcu",
"pipelineError": "Napaka obdelovalnega cevovoda"
},
+ "payloadSizeLimitOmitted": "Vsebina izpuščena — ta razdelek je presegel omejitev velikosti dnevnika klicev (CALL_LOG_PIPELINE_MAX_SIZE_KB) in ni bil shranjen; ne gre za dejansko napako ponudnika.",
"payloadMissing": "Artefakt s podrobno vsebino za ta vnos v dnevniku ni več na voljo.",
"payloadCorrupt": "Artefakta s podrobno vsebino ni bilo mogoče razčleniti.",
"notAvailable": "Ni na voljo",
diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json
index c67f0eb22c..1bc27ecf97 100644
--- a/src/i18n/messages/sr.json
+++ b/src/i18n/messages/sr.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Одговор клијенту",
"pipelineError": "Грешка тока обраде"
},
+ "payloadSizeLimitOmitted": "Садржај изостављен — овај одељак је премашио ограничење величине дневника позива (CALL_LOG_PIPELINE_MAX_SIZE_KB) и није сачуван; није реч о стварној грешци провајдера.",
"payloadMissing": "Детаљни артефакт корисног садржаја више није доступан за овај унос дневника.",
"payloadCorrupt": "Детаљни артефакт корисног садржаја није могао бити рашчлањен.",
"notAvailable": "Н/П",
diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json
index 0fec4a0513..a0aa94fc7c 100644
--- a/src/i18n/messages/sv.json
+++ b/src/i18n/messages/sv.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Klientens Svar",
"pipelineError": "Pipeline-fel"
},
+ "payloadSizeLimitOmitted": "Innehåll utelämnat — det här avsnittet överskred storleksgränsen för anropsloggen (CALL_LOG_PIPELINE_MAX_SIZE_KB) och sparades inte; det är inte ett verkligt uppströmsfel.",
"payloadMissing": "Den detaljerade nyttolasten artefakt är inte längre tillgänglig för denna loggpost.",
"payloadCorrupt": "Detaljerad payload-artikel kunde inte tolkas.",
"notAvailable": "Ej tillämpligt",
diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json
index 61f43b7764..9b70afd273 100644
--- a/src/i18n/messages/sw.json
+++ b/src/i18n/messages/sw.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Majibu ya Mteja",
"pipelineError": "Kosa la Pipeline"
},
+ "payloadSizeLimitOmitted": "Maudhui yameachwa — sehemu hii ilizidi kikomo cha ukubwa wa kumbukumbu ya miito (CALL_LOG_PIPELINE_MAX_SIZE_KB) na haikuhifadhiwa; hili si kosa halisi la mtoa huduma.",
"payloadMissing": "Kipande cha maelezo ya mzigo hakipatikani tena kwa ajili ya kipande hiki cha kumbukumbu.",
"payloadCorrupt": "Kipande cha mzigo kilichofafanuliwa hakiwezi kufasiriwa.",
"notAvailable": "Haitumiki",
diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json
index 20a4322188..41d8a923c3 100644
--- a/src/i18n/messages/ta.json
+++ b/src/i18n/messages/ta.json
@@ -11411,6 +11411,7 @@
"clientResponse": "கிளையனின் பதில்",
"pipelineError": "பைப்லைன் பிழை"
},
+ "payloadSizeLimitOmitted": "பேலோடு தவிர்க்கப்பட்டது — இந்தப் பகுதி அழைப்புப் பதிவின் அளவு வரம்பை (CALL_LOG_PIPELINE_MAX_SIZE_KB) மீறியதால் சேமிக்கப்படவில்லை; இது உண்மையான அப்ஸ்ட்ரீம் பிழை அல்ல.",
"payloadMissing": "இந்த பதிவு நுழைவுக்கு விரிவான payload கலைப்பொருள் இனி கிடைக்கவில்லை.",
"payloadCorrupt": "விவரமான payload கலைப்பொருள் பகுப்பாய்வு செய்ய முடியவில்லை.",
"notAvailable": "பொருந்தாது",
diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json
index 8b7a85f744..89fe9201fc 100644
--- a/src/i18n/messages/te.json
+++ b/src/i18n/messages/te.json
@@ -11411,6 +11411,7 @@
"clientResponse": "క్లయింట్ ప్రతిస్పందన",
"pipelineError": "పైప్లైన్ లో పొరపాటు"
},
+ "payloadSizeLimitOmitted": "పేలోడ్ వదిలివేయబడింది — ఈ విభాగం కాల్ లాగ్ పరిమాణ పరిమితిని (CALL_LOG_PIPELINE_MAX_SIZE_KB) మించిపోయినందున నిల్వ చేయబడలేదు; ఇది నిజమైన అప్స్ట్రీమ్ లోపం కాదు.",
"payloadMissing": "ఈ లాగ్ ఎంట్రీకి సంబంధించి వివరమైన పేమెంట్ ఆర్టిఫాక్ట్ అందుబాటులో లేదు.",
"payloadCorrupt": "వివరమైన పేమెంట్ ఆర్టిఫాక్ట్ను పార్స్ చేయలేకపోయింది.",
"notAvailable": "వర్తించదు",
diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json
index 042d87ad3c..bafe833e75 100644
--- a/src/i18n/messages/th.json
+++ b/src/i18n/messages/th.json
@@ -11411,6 +11411,7 @@
"clientResponse": "การตอบสนองของลูกค้า",
"pipelineError": "ข้อผิดพลาดของ Pipeline"
},
+ "payloadSizeLimitOmitted": "ละเว้นเพย์โหลด — ส่วนนี้เกินขีดจำกัดขนาดของบันทึกการเรียก (CALL_LOG_PIPELINE_MAX_SIZE_KB) จึงไม่ถูกจัดเก็บ ไม่ใช่ข้อผิดพลาดจริงจากผู้ให้บริการต้นทาง",
"payloadMissing": "ข้อมูลรายละเอียดของ payload artifact ไม่สามารถใช้งานได้อีกต่อไปสำหรับรายการบันทึกนี้.",
"payloadCorrupt": "ไม่สามารถแยกวิเคราะห์ข้อมูลพารามิเตอร์ที่ละเอียดได้。",
"notAvailable": "ไม่สามารถใช้ได้",
diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json
index 9cf33051d4..8007f6c2c0 100644
--- a/src/i18n/messages/tr.json
+++ b/src/i18n/messages/tr.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Müşteri Yanıtı",
"pipelineError": "Pipeline Hatası"
},
+ "payloadSizeLimitOmitted": "İçerik atlandı — bu bölüm çağrı günlüğü boyut sınırını (CALL_LOG_PIPELINE_MAX_SIZE_KB) aştı ve kaydedilmedi; bu gerçek bir sağlayıcı hatası değildir.",
"payloadMissing": "Bu günlük girişi için ayrıntılı yük nesnesi artık mevcut değil.",
"payloadCorrupt": "Ayrıntılı yük nesnesi ayrıştırılamadı.",
"notAvailable": "Yok",
diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json
index 905f24cf88..f73c30a786 100644
--- a/src/i18n/messages/uk-UA.json
+++ b/src/i18n/messages/uk-UA.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Відповідь клієнта",
"pipelineError": "Помилка конвеєра"
},
+ "payloadSizeLimitOmitted": "Вміст пропущено — цей розділ перевищив обмеження розміру журналу викликів (CALL_LOG_PIPELINE_MAX_SIZE_KB) і не був збережений; це не справжня помилка провайдера.",
"payloadMissing": "Детальний артефакт корисного навантаження більше недоступний для цього запису журналу.",
"payloadCorrupt": "Не вдалося розібрати детальний артефакт корисного навантаження.",
"notAvailable": "Н/Д",
diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json
index 19a0010436..39bc2f467c 100644
--- a/src/i18n/messages/ur.json
+++ b/src/i18n/messages/ur.json
@@ -11411,6 +11411,7 @@
"clientResponse": "کلائنٹ کا جواب",
"pipelineError": "پائپ لائن کی خرابی"
},
+ "payloadSizeLimitOmitted": "پے لوڈ چھوڑ دیا گیا — یہ حصہ کال لاگ کی سائز حد (CALL_LOG_PIPELINE_MAX_SIZE_KB) سے تجاوز کر گیا اور محفوظ نہیں کیا گیا؛ یہ حقیقی اپ اسٹریم خرابی نہیں ہے۔",
"payloadMissing": "اس لاگ اندراج کے لیے تفصیلی پیلوڈ آرٹيفیکٹ اب دستیاب نہیں ہے۔",
"payloadCorrupt": "تفصیلی پیلوڈ آرٹفیکٹ کو پارس نہیں کیا جا سکا۔",
"notAvailable": "دستیاب نہیں",
diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json
index ecd45c966f..bf3c7a9dea 100644
--- a/src/i18n/messages/uz.json
+++ b/src/i18n/messages/uz.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Mijozga javob",
"pipelineError": "Konveyer xatosi"
},
+ "payloadSizeLimitOmitted": "Kontent tashlab ketildi — bu boʻlim chaqiruvlar jurnalining hajm chegarasidan (CALL_LOG_PIPELINE_MAX_SIZE_KB) oshib ketdi va saqlanmadi; bu haqiqiy provayder xatosi emas.",
"payloadMissing": "Bu jurnal yozuvi uchun batafsil foydali yuk artefakti endi mavjud emas.",
"payloadCorrupt": "Batafsil foydali yuk artefaktini tahlil qilib boʻlmadi.",
"notAvailable": "Mavjud emas",
diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json
index d56aac4081..526c9a28ed 100644
--- a/src/i18n/messages/vi.json
+++ b/src/i18n/messages/vi.json
@@ -11411,6 +11411,7 @@
"clientResponse": "Phản hồi client",
"pipelineError": "Lỗi Pipeline"
},
+ "payloadSizeLimitOmitted": "Đã bỏ qua payload — phần này vượt quá giới hạn kích thước nhật ký cuộc gọi (CALL_LOG_PIPELINE_MAX_SIZE_KB) nên không được lưu; đây không phải lỗi thực sự từ upstream.",
"payloadMissing": "Không còn artifact payload chi tiết cho mục nhật ký này.",
"payloadCorrupt": "Không thể phân tích artifact payload chi tiết.",
"notAvailable": "Không áp dụng",
diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json
index 9544c193d6..d17dff5864 100644
--- a/src/i18n/messages/yo.json
+++ b/src/i18n/messages/yo.json
@@ -11464,7 +11464,8 @@
"correlationIdValue": "ID Ìbámu: {id}",
"detailedPayloadInfo": "Kọ́kọ́ mú ìforúkọsílẹ̀ alálàyé ṣiṣẹ́ bí o bá fẹ́ àfihàn àkóónú client/provider onípele mẹ́rin fún àwọn ìbéèrè tuntun.",
"copyAll": "Ṣe àdàkọ gbogbo rẹ̀",
- "copiedAll": "A ti ṣe àdàkọ gbogbo rẹ̀"
+ "copiedAll": "A ti ṣe àdàkọ gbogbo rẹ̀",
+ "payloadSizeLimitOmitted": "A fo ẹrù-ìsọfúnni sílẹ̀ — apá yìí kọjá ààlà ìwọ̀n àkọsílẹ̀ ìpè (CALL_LOG_PIPELINE_MAX_SIZE_KB), a kò sì tọ́jú rẹ̀; kì í ṣe àṣìṣe gidi láti ọ̀dọ̀ olùpèsè."
}
},
"proxyLogger": {
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 163ba54af2..04a182d854 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -11411,6 +11411,7 @@
"clientResponse": "客户端响应",
"pipelineError": "管道错误"
},
+ "payloadSizeLimitOmitted": "已省略负载 — 此部分超出了调用日志大小限制(CALL_LOG_PIPELINE_MAX_SIZE_KB),未被存储;这不是真正的上游错误。",
"payloadMissing": "此日志条目的详细有效负载工件不再可用。",
"payloadCorrupt": "无法解析详细的有效负载工件。",
"notAvailable": "不适用",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 110c9d2cbe..589394fe29 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -11411,6 +11411,7 @@
"clientResponse": "客戶回應",
"pipelineError": "管道錯誤"
},
+ "payloadSizeLimitOmitted": "已省略承載內容 — 此區段超出了呼叫日誌大小上限(CALL_LOG_PIPELINE_MAX_SIZE_KB),因此未被儲存;這並非真正的上游錯誤。",
"payloadMissing": "此日誌條目的詳細有效負載工件不再可用。",
"payloadCorrupt": "無法解析詳細的有效負載工件。",
"notAvailable": "不適用",
diff --git a/src/lib/cli-helper/log-streamer.ts b/src/lib/cli-helper/log-streamer.ts
index 06dbffbb2d..2890b4a959 100644
--- a/src/lib/cli-helper/log-streamer.ts
+++ b/src/lib/cli-helper/log-streamer.ts
@@ -12,7 +12,12 @@ export interface LogStream {
}
export function createLogStream(options: LogStreamOptions = {}): LogStream {
- const baseUrl = options.baseUrl || "http://localhost:20128";
+ const port = process.env.PORT || process.env.DASHBOARD_PORT || 20128;
+ const baseUrl =
+ options.baseUrl ||
+ process.env.OMNIROUTE_BASE_URL ||
+ process.env.BASE_URL ||
+ `http://localhost:${port}`;
const filters = options.filters || [];
const follow = options.follow ?? false;
const timeout = options.timeout || 30000;
diff --git a/src/lib/cli-helper/tool-detector.ts b/src/lib/cli-helper/tool-detector.ts
index 38a2b5d289..01b90a07a7 100644
--- a/src/lib/cli-helper/tool-detector.ts
+++ b/src/lib/cli-helper/tool-detector.ts
@@ -77,9 +77,11 @@ function expandHome(p: string): string {
function isConfigured(content: string, baseUrl: string): boolean {
const normalized = baseUrl.replace(/\/+$/, "");
+ const port = process.env.PORT || process.env.DASHBOARD_PORT;
return (
content.includes(normalized) ||
content.includes("localhost:20128") ||
+ (!!port && content.includes(`localhost:${port}`)) ||
content.includes("OMNIROUTE_BASE_URL")
);
}
@@ -170,7 +172,9 @@ export async function detectTool(id: string): Promise {
: getCliPrimaryConfigPath(tool.id) ||
(tool.id === "opencode" ? resolveOpencodeConfigPath() : "");
const configContents = await readConfigFile(configPath);
- const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
+ const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || 20128;
+ const configured =
+ !!configContents && isConfigured(configContents, `http://localhost:${defaultPort}`);
const result: DetectedTool = {
id: canonicalId,
@@ -187,12 +191,14 @@ export async function detectTool(id: string): Promise {
try {
const roles = await getCurrentHermesAgentRoles();
const richRoles: Record = {};
+ const currentPort = String(process.env.PORT || process.env.DASHBOARD_PORT || 20128);
Object.entries(roles).forEach(([role, info]) => {
const usingOmni =
info?.provider === "omniroute" ||
(info?.base_url || "").includes("20128") ||
- (info?.base_url || "").includes("localhost:20128");
+ (info?.base_url || "").includes(currentPort) ||
+ (info?.base_url || "").includes("localhost");
richRoles[role] = {
model: info.model,
diff --git a/src/lib/combos/invariants.ts b/src/lib/combos/invariants.ts
index 302bf61296..6759443249 100644
--- a/src/lib/combos/invariants.ts
+++ b/src/lib/combos/invariants.ts
@@ -14,10 +14,19 @@ const FAMILY_PATTERNS: ReadonlyArray<[string, RegExp]> = [
];
function strings(value: unknown): string[] {
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : [];
}
-function modelFamily(model: string): string | null {
+/**
+ * Detect the model "family" (gpt/claude/gemini/...) from a bare or
+ * provider-prefixed model id. Exported for callers that need to know
+ * whether a candidate step would actually violate an existing
+ * `allowedModelFamilies` restriction (#13951) rather than only the
+ * `validateComboInvariant` throw path below.
+ */
+export function modelFamily(model: string): string | null {
const bare = model.slice(model.lastIndexOf("/") + 1);
return FAMILY_PATTERNS.find(([, pattern]) => pattern.test(bare))?.[0] ?? null;
}
diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts
index 0764e10335..f3d2a814e2 100644
--- a/src/lib/db/apiKeys.ts
+++ b/src/lib/db/apiKeys.ts
@@ -85,6 +85,7 @@ interface CreateApiKeyOptions {
allowedModels?: string[];
allowedCombos?: string[];
allowedConnections?: string[];
+ expiresAt?: string | null;
}
export type { AccessSchedule, RateLimitRule } from "./apiKeys/types";
@@ -374,6 +375,15 @@ async function getModelPermissionCandidates(modelId: string): Promise
return Array.from(candidates);
}
+export async function isModelBlockedByPatterns(
+ blockedModels: string[] | null | undefined,
+ modelId: string
+): Promise {
+ if (!blockedModels?.length) return false;
+ const candidates = await getModelPermissionCandidates(modelId);
+ return blockedModels.some((pattern) => modelPatternMatches(pattern, candidates));
+}
+
async function getPublishedModelLookupTarget(
modelId: string
): Promise<{ providerId: string; modelId: string } | null> {
@@ -450,7 +460,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
"SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, allow_auto_combos, catalog_scope, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtInsertKey = db.prepare(
- "INSERT INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ "INSERT INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
}
@@ -710,6 +720,7 @@ export async function createApiKey(
noLog: false,
allowUsageCommand: false,
createdAt: now,
+ expiresAt: options.expiresAt ?? null,
scopes,
};
@@ -727,7 +738,8 @@ export async function createApiKey(
apiKey.createdAt,
apiKey.key.slice(0, 12),
await hashKey(apiKey.key),
- JSON.stringify(scopes)
+ JSON.stringify(scopes),
+ apiKey.expiresAt
);
setNoLog(apiKey.id, false);
diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts
index 774ab27e8d..f9c380a95b 100644
--- a/src/lib/tokenHealthCheck.ts
+++ b/src/lib/tokenHealthCheck.ts
@@ -11,7 +11,11 @@
* updates the DB, and logs the result.
*/
-import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers";
+import {
+ getProviderConnections,
+ getProviderConnectionById,
+ updateProviderConnection,
+} from "@/lib/db/providers";
import { getCachedProviderConnectionById } from "@/lib/db/readCache";
import { getSettings } from "@/lib/db/settings";
import { resolveGuardedProxyConfig } from "@/lib/tokenHealthCheckProxyGuard";
@@ -42,6 +46,24 @@ const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds (restored — #77
const DEFAULT_BATCH_SIZE = 20;
const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval
const EXPIRED_RETRY_MAX = 3; // max retry attempts for expired connections before giving up
+const ROTATING_REFRESH_PROVIDERS = new Set([
+ "codex",
+ "openai",
+ "kimi-coding",
+ "cline",
+ "kiro",
+ "amazon-q",
+ "gitlab-duo",
+ "claude",
+ "openference",
+]);
+
+export function shouldNullRefreshTokenAfterUnrecoverable(provider: unknown): boolean {
+ const id = String(provider || "").toLowerCase();
+ if (id === "claude") return false;
+ return ROTATING_REFRESH_PROVIDERS.has(id);
+}
+
const EXPIRED_RETRY_BACKOFF_MIN = 5; // backoff between expired retries (minutes)
function isBuildProcess(): boolean {
@@ -65,29 +87,55 @@ export function extractResolvedProxyConfig(resolvedProxy: unknown) {
return resolvedProxy ?? null;
}
+const NUMERIC_STRING = /^\d+(\.\d+)?$/;
+
+/**
+ * Normalize any stored token-expiry value to epoch milliseconds.
+ *
+ * `provider_connections.expires_at` / `token_expires_at` are TEXT columns, so a
+ * numeric epoch written by an external sync tool reads back as a *string* —
+ * and `new Date("1789012345678")` is an Invalid Date. Both numeric shapes are
+ * accepted here with the seconds/ms heuristic the Copilot path already used,
+ * before falling back to `Date` for ISO 8601 and other date strings.
+ *
+ * @returns epoch ms, or 0 when the value carries no usable time
+ */
+export function parseTokenExpiryMs(expiresAt: unknown): number {
+ if (typeof expiresAt === "number") {
+ if (!Number.isFinite(expiresAt) || expiresAt <= 0) return 0;
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
+ }
+
+ if (typeof expiresAt === "string") {
+ const trimmed = expiresAt.trim();
+ if (!trimmed) return 0;
+
+ if (NUMERIC_STRING.test(trimmed)) {
+ const numeric = Number(trimmed);
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0;
+ return numeric < 1e12 ? numeric * 1000 : numeric;
+ }
+
+ const parsed = new Date(trimmed).getTime();
+ return Number.isFinite(parsed) ? parsed : 0;
+ }
+
+ return 0;
+}
+
function getEffectiveTokenExpiryIso(conn: any): string | null {
if (!conn || typeof conn !== "object") return null;
return conn.tokenExpiresAt || conn.expiresAt || null;
}
function getEffectiveTokenExpiryMs(conn: any): number {
- const effectiveExpiry = getEffectiveTokenExpiryIso(conn);
- if (!effectiveExpiry) return 0;
- const expiryMs = new Date(effectiveExpiry).getTime();
- return Number.isFinite(expiryMs) ? expiryMs : 0;
+ return parseTokenExpiryMs(getEffectiveTokenExpiryIso(conn));
}
const TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
function getCopilotTokenExpiryMs(expiresAt: unknown): number {
- if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
- return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
- }
- if (typeof expiresAt === "string" && expiresAt.trim()) {
- const parsed = new Date(expiresAt).getTime();
- return Number.isFinite(parsed) ? parsed : 0;
- }
- return 0;
+ return parseTokenExpiryMs(expiresAt);
}
// Providers whose OAuth flow yields only a GitHub-style access token (no
@@ -871,17 +919,6 @@ export async function checkConnection(conn) {
// and is the root cause of "adding account B invalidates account A" reports.
// The interval path is kept ONLY for non-rotating providers where token state can
// drift silently (e.g. cookie-based, opaque sessions without expires_at).
- const ROTATING_REFRESH_PROVIDERS = new Set([
- "codex",
- "openai",
- "kimi-coding",
- "cline",
- "kiro",
- "amazon-q",
- "gitlab-duo",
- "claude",
- "openference",
- ]);
const isRotatingProvider = ROTATING_REFRESH_PROVIDERS.has(
String(conn.provider || "").toLowerCase()
);
@@ -1071,7 +1108,7 @@ export async function checkConnection(conn) {
// Once used, the old token is permanently invalidated.
// Retrying will never succeed → deactivate and stop the loop.
if (isUnrecoverableRefreshError(result)) {
- const currentConnection = await getCachedProviderConnectionById(conn.id);
+ const currentConnection = await getProviderConnectionById(conn.id);
const credentialsChangedSinceSweep =
!!currentConnection &&
(currentConnection.refreshToken !== attemptedRefreshToken ||
@@ -1131,11 +1168,7 @@ export async function checkConnection(conn) {
// gemini) the stored refresh_token is the user's only recovery
// artifact — nulling it caused #3679 (the connection reports "No valid refresh
// token available" and can never recover even after re-activation). Preserve it.
- // PRESERVE_REFRESH_TOKEN_PROVIDERS (Claude) opt out too: nulling on the first
- // failure makes the #11414 retry budget above unreachable (#13183).
- ...(isRotatingProvider && !preservesRefreshTokenOnUnrecoverable(conn.provider)
- ? { refreshToken: null }
- : {}),
+ ...(shouldNullRefreshTokenAfterUnrecoverable(conn.provider) ? { refreshToken: null } : {}),
});
logError(
`${LOG_PREFIX} ✗ ${conn.provider}/${getConnectionLogLabel(conn)} — ` +
diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts
index 1fe14b98e7..c1a5d263ad 100644
--- a/src/lib/usage/callLogArtifacts.ts
+++ b/src/lib/usage/callLogArtifacts.ts
@@ -3,6 +3,12 @@ import path from "node:path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { resolveDataDir } from "../dataPaths";
import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv";
+import {
+ CALL_LOG_SIZE_LIMIT_REASON as SIZE_LIMIT_EXCEEDED_REASON,
+ CALL_LOG_BODY_OMITTED_FOR_SIZE_LIMIT as OMITTED_FOR_SIZE_LIMIT,
+ CALL_LOG_STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT as STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT,
+ isSizeLimitOmissionMarker,
+} from "@/shared/constants/callLogSizeLimitMarkers";
const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
const isBuildPhase =
@@ -12,21 +18,11 @@ const DATA_DIR = resolveDataDir({ isCloud });
export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");
export const MAX_CALL_LOG_ARTIFACT_BYTES = 512 * 1024;
-const SIZE_LIMIT_EXCEEDED_REASON = "call_log_artifact_size_limit_exceeded";
-const OMITTED_FOR_SIZE_LIMIT = "[omitted: call log artifact size limit exceeded]";
-const STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT =
- "[stream chunks omitted: call log artifact size limit exceeded]";
-
-/**
- * True for a placeholder a size-limit fallback wrote in place of a real
- * payload. Consumers that fall back from one artifact field to another
- * (`maybeEnrichCompletedDetail`) must treat a marker as absent: it is a
- * non-empty string, so a bare truthiness check happily "recovers" it and
- * overwrites the real value it was meant to stand in for.
- */
-export function isSizeLimitOmissionMarker(value: unknown): boolean {
- return value === OMITTED_FOR_SIZE_LIMIT || value === STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT;
-}
+// Re-exported for backward compatibility: consumers (completedRequestDetails.ts)
+// import this marker check from here. Definition now lives in the shared
+// constants module so the client-side detail view can use the exact same check
+// without importing this fs/path-dependent, server-only module (see #13894).
+export { isSizeLimitOmissionMarker };
// The error is the only field that says *why* a request failed, and it is
// typically ~90 bytes next to the multi-hundred-KB bodies that trip the cap.
@@ -54,7 +50,7 @@ function preserveErrorForSizeLimit(error: unknown): unknown {
if (error === null || error === undefined) return null;
let serialized: string;
try {
- serialized = typeof error === "string" ? error : JSON.stringify(error) ?? String(error);
+ serialized = typeof error === "string" ? error : (JSON.stringify(error) ?? String(error));
} catch {
// A circular or unserializable error must not take the whole artifact down.
serialized = String(error);
diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts
index 4bebc2866d..9518758974 100644
--- a/src/lib/usage/callLogs.ts
+++ b/src/lib/usage/callLogs.ts
@@ -452,6 +452,12 @@ function getLegacyInlineDetail(id: string) {
async function saveCallLogOperation(entry: any): Promise {
try {
+ // Bind the DB instance up front, before any await (resolveAccountName,
+ // writeCallArtifactAsync). If the singleton is reset/closed while this
+ // operation awaits, the insert must target the instance this request
+ // started against — a closed handle fails into the catch below instead of
+ // silently writing into whatever database opened afterwards (#12780).
+ const db = getDbInstance();
const apiKeyContext = getCallLogApiKeyContext();
// `||` (not `??`): an empty-string apiKeyId/apiKeyName is "unattributed",
// same as before this fallback existed — it must not be persisted verbatim
@@ -591,7 +597,6 @@ async function saveCallLogOperation(entry: any): Promise {
}
}
- const db = getDbInstance();
db.prepare(
`
INSERT INTO call_logs (
diff --git a/src/lib/wellKnown.ts b/src/lib/wellKnown.ts
index 7a6f2a35bf..341d639888 100644
--- a/src/lib/wellKnown.ts
+++ b/src/lib/wellKnown.ts
@@ -10,5 +10,6 @@ export function getBaseUrl(request?: NextRequest | null): string {
if (process.env.OMNIROUTE_BASE_URL) return process.env.OMNIROUTE_BASE_URL;
// Direct route-handler invocation (unit tests, programmatic calls) passes no
// Request — fall back to the default local gateway origin instead of crashing.
- return request?.nextUrl?.origin ?? "http://localhost:20128";
+ const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || 20128;
+ return request?.nextUrl?.origin ?? `http://localhost:${defaultPort}`;
}
diff --git a/src/mitm/handlers/base.ts b/src/mitm/handlers/base.ts
index 18b4a86dfe..943e1c38b6 100644
--- a/src/mitm/handlers/base.ts
+++ b/src/mitm/handlers/base.ts
@@ -179,7 +179,9 @@ export abstract class MitmHandlerBase {
path: string,
headers: IncomingHttpHeaders,
): Promise {
- const base = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128";
+ const port = process.env.API_PORT || process.env.PORT || 20128;
+ const base =
+ process.env.OMNIROUTE_BASE_URL ?? process.env.BASE_URL ?? `http://127.0.0.1:${port}`;
const url = `${base.replace(/\/+$/, "")}${path}`;
const apiKey = process.env.ROUTER_API_KEY ?? "";
diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs
index da0d6bba3e..7a6a61cb82 100644
--- a/src/mitm/server.cjs
+++ b/src/mitm/server.cjs
@@ -42,7 +42,7 @@ const MITM_IDLE_TIMEOUT_MS =
const ROUTER_BASE_URL = (
process.env.OMNIROUTE_BASE_URL ||
process.env.BASE_URL ||
- "http://localhost:20128"
+ `http://localhost:${process.env.API_PORT || process.env.PORT || 20128}`
)
.trim()
.replace(/\/+$/, "");
diff --git a/src/server/origin/publicOrigin.ts b/src/server/origin/publicOrigin.ts
index c925b8e166..9cc6e78f21 100644
--- a/src/server/origin/publicOrigin.ts
+++ b/src/server/origin/publicOrigin.ts
@@ -3,10 +3,7 @@ import { PEER_IP_HEADER } from "@/server/authz/headers";
import { resolveStampedPeer } from "@/server/authz/peerStamp";
export type PublicOriginSource =
- | "configured"
- | "trusted-forwarded"
- | "request-url"
- | "direct-local-host";
+ "configured" | "trusted-forwarded" | "request-url" | "direct-local-host";
export interface PublicOriginCandidate {
origin: string;
@@ -200,7 +197,7 @@ function directLocalHostOrigin(request: Request): string | null {
if (classifyHostLocality(peer) === "remote") return null;
const rawHost = trustsForwardedHeaders(request)
- ? firstHeaderValue(request.headers.get("x-forwarded-host")) ?? request.headers.get("host")
+ ? (firstHeaderValue(request.headers.get("x-forwarded-host")) ?? request.headers.get("host"))
: request.headers.get("host");
const host = sanitizeForwardedHost(rawHost);
if (!host) return null;
@@ -246,7 +243,8 @@ export function resolvePublicOrigin(request: Request): PublicOriginCandidate {
const requestOrigin = requestUrlOrigin(request);
if (requestOrigin) return { origin: requestOrigin, source: "request-url" };
- return { origin: "http://localhost:20128", source: "request-url" };
+ const defaultPort = process.env.PORT || process.env.DASHBOARD_PORT || "20128";
+ return { origin: `http://localhost:${defaultPort}`, source: "request-url" };
}
export function validateBrowserMutationOrigin(request: Request): BrowserMutationOriginVerdict {
diff --git a/src/server/ws/liveServerAllowList.ts b/src/server/ws/liveServerAllowList.ts
index 1f3101f054..c11e09550b 100644
--- a/src/server/ws/liveServerAllowList.ts
+++ b/src/server/ws/liveServerAllowList.ts
@@ -45,7 +45,17 @@ export function parseCsvEnv(value: string | undefined | null): Set {
*/
export function buildAllowedOrigins(env: NodeJS.ProcessEnv = process.env): Set {
const extra = parseCsvEnv(env.LIVE_WS_ALLOWED_ORIGINS);
- return new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra]);
+ const runtimePort = env.PORT || env.DASHBOARD_PORT;
+ const dynamicDefaults: string[] = [];
+ if (runtimePort && runtimePort !== "20128") {
+ dynamicDefaults.push(
+ `http://127.0.0.1:${runtimePort}`,
+ `http://localhost:${runtimePort}`,
+ `http://[::1]:${runtimePort}`,
+ `http://0.0.0.0:${runtimePort}`
+ );
+ }
+ return new Set([...DEFAULT_ALLOWED_ORIGINS, ...dynamicDefaults, ...extra]);
}
/**
diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx
index cbdce62a01..d5d5521a38 100644
--- a/src/shared/components/RequestLoggerDetail.sections.tsx
+++ b/src/shared/components/RequestLoggerDetail.sections.tsx
@@ -13,6 +13,47 @@ import {
} from "@/shared/hooks/useTimestampTitles";
import { JsonTreeExpandControls } from "@/shared/components/JsonTreeExpandControls";
import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
+import {
+ isPipelineSizeLimitMarker,
+ isSizeLimitOmissionMarker,
+} from "@/shared/constants/callLogSizeLimitMarkers";
+
+// ─── Size-limit omission detection (#13894) ─────────────────────────────────
+// A size-limited call-log artifact does not simply drop a payload -- it writes
+// an explicit marker in its place (see callLogArtifacts.ts's
+// omitOversizedPipeline()/buildMinimalArtifactForSizeLimit()). Before this fix
+// the detail view fed that marker straight into the generic JSON/``
+// renderer, so a size-limit omission was indistinguishable from a real
+// upstream error or a genuinely empty payload -- a silent fallback. These
+// helpers turn the marker into an explicit, labeled notice instead.
+
+/** Builds the pipeline payload sections, replacing the `error` marker object
+ * left by a size-limited pipeline capture with an explicit notice entry
+ * instead of letting it render as if it were a real pipeline error. */
+export function buildPipelinePayloadSections(entries, pipelinePayloads) {
+ return entries
+ .map(([key, title]) => {
+ const value = pipelinePayloads?.[key];
+ if (key === "error" && isPipelineSizeLimitMarker(value)) {
+ return { key, title, json: null, notice: true };
+ }
+ if (value === null || value === undefined) return { key, title, json: null, notice: false };
+ let json;
+ try {
+ json = JSON.stringify(value, null, 2);
+ } catch {
+ json = String(value);
+ }
+ return { key, title, json, notice: false };
+ })
+ .filter((section) => section.json || section.notice);
+}
+
+/** True when a top-level requestBody/responseBody was replaced by the
+ * size-limit omission placeholder string rather than genuinely absent. */
+export function isBodySizeLimitOmission(value) {
+ return isSizeLimitOmissionMarker(value);
+}
// ─── Payload Code Block ─────────────────────────────────────────────────────
// Renders parsed payloads as a collapsible JSON tree (react18-json-view) so
@@ -21,11 +62,15 @@ import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
// the plain dump for anything that isn't valid JSON (e.g. a captured
// error string), since json is display text sourced from JSON.stringify with
// a String() fallback on failure -- it is not guaranteed parseable.
+// `notice`, when true, takes over rendering entirely: it means `json` is not a
+// real payload but a size-limit omission marker (#13894) that must be shown as
+// an explicit, labeled notice rather than a generic JSON/error dump.
export function PayloadSection({
title,
sectionId,
json,
+ notice = false,
onCopy,
collapsible = true,
defaultOpen = true,
@@ -78,20 +123,28 @@ export function PayloadSection({
)}
-
+ {!notice && (
+
+ )}
{parsedJson !== null && }
- {open && parsedJson !== null && (
+ {open && notice && (
+
+ warning
+ {t("payloadSizeLimitOmitted")}
+
+ )}
+ {open && !notice && parsedJson !== null && (
)}
- {open && parsedJson === null && (
+ {open && !notice && parsedJson === null && (
{json}
diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx
index 0721a34e58..acc5fd0035 100644
--- a/src/shared/components/RequestLoggerDetail.tsx
+++ b/src/shared/components/RequestLoggerDetail.tsx
@@ -20,6 +20,8 @@ import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
import {
PayloadSection,
ConversationContextSection,
+ buildPipelinePayloadSections,
+ isBodySizeLimitOmission,
} from "@/shared/components/RequestLoggerDetail.sections";
// ─── Copy-all composition ────────────────────────────────────────────────────
@@ -470,22 +472,21 @@ export default function RequestLoggerDetail({
const pipelinePayloads = detail?.pipelinePayloads || null;
const payloadSections = pipelinePayloads
- ? [
- ["clientRawRequest", t("payload.clientRawRequest")],
- ["clientRequest", t("payload.clientRequest")],
- ["openaiRequest", t("payload.openaiRequest")],
- ["providerRequest", t("payload.providerRequest")],
- ["providerResponse", t("payload.providerResponse")],
- ["clientResponse", t("payload.clientResponse")],
- ["error", t("payload.pipelineError")],
- ]
- .map(([key, title]) => ({
- key,
- title,
- json: toPrettyJson(pipelinePayloads[key]),
- }))
- .filter((section) => section.json)
+ ? buildPipelinePayloadSections(
+ [
+ ["clientRawRequest", t("payload.clientRawRequest")],
+ ["clientRequest", t("payload.clientRequest")],
+ ["openaiRequest", t("payload.openaiRequest")],
+ ["providerRequest", t("payload.providerRequest")],
+ ["providerResponse", t("payload.providerResponse")],
+ ["clientResponse", t("payload.clientResponse")],
+ ["error", t("payload.pipelineError")],
+ ],
+ pipelinePayloads
+ )
: [];
+ const requestBodyOmitted = isBodySizeLimitOmission(detail?.requestBody);
+ const responseBodyOmitted = isBodySizeLimitOmission(detail?.responseBody);
const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null;
const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null;
const streamChunks = (() => {
@@ -1155,6 +1156,7 @@ export default function RequestLoggerDetail({
title={section.title}
sectionId={section.key}
json={section.json}
+ notice={section.notice}
onCopy={() => onCopy(section.json)}
/>
))}
@@ -1164,6 +1166,7 @@ export default function RequestLoggerDetail({
title={t("responsePayloadLegacy")}
sectionId="responsePayloadLegacy"
json={responseJson}
+ notice={responseBodyOmitted}
onCopy={() => onCopy(responseJson)}
/>
)}
@@ -1173,6 +1176,7 @@ export default function RequestLoggerDetail({
title={t("requestPayloadLegacy")}
sectionId="requestPayloadLegacy"
json={requestJson}
+ notice={requestBodyOmitted}
onCopy={() => onCopy(requestJson)}
/>
)}
diff --git a/src/shared/constants/callLogSizeLimitMarkers.ts b/src/shared/constants/callLogSizeLimitMarkers.ts
new file mode 100644
index 0000000000..1a794114ea
--- /dev/null
+++ b/src/shared/constants/callLogSizeLimitMarkers.ts
@@ -0,0 +1,40 @@
+// Sentinel markers written by src/lib/usage/callLogArtifacts.ts when a call-log
+// artifact's request/response body or pipeline payload had to be dropped because
+// it exceeded the configured size cap (CALL_LOG_PIPELINE_MAX_SIZE_KB /
+// MAX_CALL_LOG_ARTIFACT_BYTES). Kept here — not inside callLogArtifacts.ts, which
+// pulls in `fs`/`path` and cannot be imported by a client component — so the
+// artifact writer and the request-log detail view (RequestLoggerDetail.tsx) share
+// one definition of "this is a size-limit omission" instead of each guessing at
+// the shape independently (see issue #13894: the previous frontend rendered the
+// pipeline marker verbatim as if it were a real upstream error).
+
+export const CALL_LOG_SIZE_LIMIT_REASON = "call_log_artifact_size_limit_exceeded";
+
+export const CALL_LOG_BODY_OMITTED_FOR_SIZE_LIMIT =
+ "[omitted: call log artifact size limit exceeded]";
+
+export const CALL_LOG_STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT =
+ "[stream chunks omitted: call log artifact size limit exceeded]";
+
+/**
+ * True for a placeholder a size-limit fallback wrote in place of a real
+ * requestBody/responseBody/stream-chunk payload.
+ */
+export function isSizeLimitOmissionMarker(value: unknown): boolean {
+ return (
+ value === CALL_LOG_BODY_OMITTED_FOR_SIZE_LIMIT ||
+ value === CALL_LOG_STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT
+ );
+}
+
+/**
+ * True for the `pipeline.error` marker object omitOversizedPipeline() writes in
+ * place of the real pipeline payload once it exceeds CALL_LOG_PIPELINE_MAX_SIZE_KB.
+ * Checked by shape (not just truthiness) so a real upstream error that happens to
+ * be named `error` is never mistaken for the size-limit marker.
+ */
+export function isPipelineSizeLimitMarker(pipelineError: unknown): boolean {
+ if (!pipelineError || typeof pipelineError !== "object") return false;
+ const candidate = pipelineError as { _omniroute_truncated?: unknown; reason?: unknown };
+ return candidate._omniroute_truncated === true && candidate.reason === CALL_LOG_SIZE_LIMIT_REASON;
+}
diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts
index 57c19428f6..0498d98842 100644
--- a/src/shared/constants/modelSpecs.ts
+++ b/src/shared/constants/modelSpecs.ts
@@ -197,54 +197,6 @@ export const MODEL_SPECS: Record = {
supportsTools: true,
supportsVision: true,
},
- // Output limit published at https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash.
- // Thinking budgets follow the 3.7 Flash high/medium/low/tiered split.
- "gemini-3.8-flash-high": {
- maxOutputTokens: 65536,
- contextWindow: 1048576,
- defaultThinkingBudget: 24576,
- thinkingBudgetCap: 24576,
- supportsThinking: true,
- supportsTools: true,
- supportsVision: true,
- },
- "gemini-3.8-flash-medium": {
- maxOutputTokens: 65536,
- contextWindow: 1048576,
- defaultThinkingBudget: 8192,
- thinkingBudgetCap: 24576,
- supportsThinking: true,
- supportsTools: true,
- supportsVision: true,
- },
- "gemini-3.8-flash-low": {
- maxOutputTokens: 65536,
- contextWindow: 1048576,
- defaultThinkingBudget: 1024,
- thinkingBudgetCap: 24576,
- supportsThinking: true,
- supportsTools: true,
- supportsVision: true,
- },
- "gemini-3.8-flash": {
- maxOutputTokens: 65536,
- contextWindow: 1048576,
- defaultThinkingBudget: 8192,
- thinkingBudgetCap: 24576,
- supportsThinking: true,
- supportsTools: true,
- supportsVision: true,
- aliases: ["gemini-3.8-flash-tiered"],
- },
- "gemini-3.8-flash-tiered": {
- maxOutputTokens: 65536,
- contextWindow: 1048576,
- defaultThinkingBudget: 8192,
- thinkingBudgetCap: 24576,
- supportsThinking: true,
- supportsTools: true,
- supportsVision: true,
- },
// Gemini 3.7 Flash tiers: high 24.5k, medium 8k, low 1k thinking tokens.
"gemini-3.7-flash-high": {
@@ -293,6 +245,53 @@ export const MODEL_SPECS: Record = {
supportsTools: true,
supportsVision: true,
},
+ // ── Gemini 3.8 Flash (current Antigravity/AGY live tiers) ─────────
+ "gemini-3.8-flash-high": {
+ maxOutputTokens: 65536,
+ contextWindow: 1048576,
+ defaultThinkingBudget: 24576,
+ thinkingBudgetCap: 24576,
+ supportsThinking: true,
+ supportsTools: true,
+ supportsVision: true,
+ },
+ "gemini-3.8-flash-medium": {
+ maxOutputTokens: 65536,
+ contextWindow: 1048576,
+ defaultThinkingBudget: 8192,
+ thinkingBudgetCap: 24576,
+ supportsThinking: true,
+ supportsTools: true,
+ supportsVision: true,
+ },
+ "gemini-3.8-flash-low": {
+ maxOutputTokens: 65536,
+ contextWindow: 1048576,
+ defaultThinkingBudget: 1024,
+ thinkingBudgetCap: 24576,
+ supportsThinking: true,
+ supportsTools: true,
+ supportsVision: true,
+ },
+ "gemini-3.8-flash": {
+ maxOutputTokens: 65536,
+ contextWindow: 1048576,
+ defaultThinkingBudget: 8192,
+ thinkingBudgetCap: 24576,
+ supportsThinking: true,
+ supportsTools: true,
+ supportsVision: true,
+ aliases: ["gemini-3.8-flash-tiered"],
+ },
+ "gemini-3.8-flash-tiered": {
+ maxOutputTokens: 65536,
+ contextWindow: 1048576,
+ defaultThinkingBudget: 8192,
+ thinkingBudgetCap: 24576,
+ supportsThinking: true,
+ supportsTools: true,
+ supportsVision: true,
+ },
// Provider-neutral compatibility for providers that still serve Gemini 3.6.
// Antigravity/AGY availability is governed by their own provider catalogs and
diff --git a/src/shared/constants/upstreamHeaders.ts b/src/shared/constants/upstreamHeaders.ts
index 5d9d7f7f08..fcc3f03d18 100644
--- a/src/shared/constants/upstreamHeaders.ts
+++ b/src/shared/constants/upstreamHeaders.ts
@@ -2,6 +2,12 @@
* User-supplied upstream extra headers: names we never forward (Host / hop-by-hop / framing).
* Changing this list requires syncing: `sanitizeUpstreamHeadersMap` (models.ts), Zod
* `upstreamHeaderNameSchema` / record refine (schemas.ts), and `upstream-headers-sanitize` tests.
+ *
+ * The forwarding/IP set (x-forwarded-for, x-real-ip, cf-connecting-ip, forwarded, via, …)
+ * is forbidden so the client-origin IP can never be disclosed (or spoofed) to the upstream
+ * provider through an operator-set custom upstream header. This mirrors the established
+ * scrubbers/denylists already used by the Antigravity (`antigravityHeaderScrub.ts`) and
+ * Cursor CLI (`cursorCliProxy.ts`) paths, extended here to cover every provider.
*/
const FORBIDDEN = new Set(
[
@@ -24,6 +30,18 @@ const FORBIDDEN = new Set(
"te",
"trailer",
"upgrade",
+ // Origin-IP disclosure: never send the client's forwarding headers upstream.
+ "x-forwarded-for",
+ "x-forwarded-host",
+ "x-forwarded-proto",
+ "x-forwarded-port",
+ "x-forwarded-server",
+ "x-real-ip",
+ "cf-connecting-ip",
+ "true-client-ip",
+ "client-ip",
+ "forwarded",
+ "via",
].map((s) => s.toLowerCase())
);
diff --git a/src/shared/hooks/useDisplayBaseUrl.ts b/src/shared/hooks/useDisplayBaseUrl.ts
index c58e2344f9..3984aa487e 100644
--- a/src/shared/hooks/useDisplayBaseUrl.ts
+++ b/src/shared/hooks/useDisplayBaseUrl.ts
@@ -209,7 +209,11 @@ export function resolveDisplayBaseUrl(
return joinOriginAndBasePath(configuredUrl, basePath);
}
- const fallback = currentOrigin ?? configuredUrl ?? DEFAULT_DISPLAY_BASE_URL;
+ const portFallback =
+ typeof process !== "undefined" && (process.env.NEXT_PUBLIC_PORT || process.env.PORT)
+ ? `http://localhost:${process.env.NEXT_PUBLIC_PORT || process.env.PORT}`
+ : DEFAULT_DISPLAY_BASE_URL;
+ const fallback = currentOrigin ?? configuredUrl ?? portFallback;
return joinOriginAndBasePath(fallback, basePath);
}
diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts
index f838e92224..ef38f4086a 100644
--- a/src/shared/middleware/chatBodyAdmission.ts
+++ b/src/shared/middleware/chatBodyAdmission.ts
@@ -38,6 +38,7 @@ import {
type IngestBudgetAcquireResult,
} from "./ingestByteAdmission";
import {
+ checkResourcePressureGuard,
getResourcePressureObservation,
type PressureSeverity,
} from "@omniroute/open-sse/utils/resourcePressure.ts";
@@ -217,10 +218,45 @@ export type ChatAdmissionShedReason =
| "inflight_bytes_budget"
| "resource_pressure";
-/** Read cached pressure severity; sampling failures must not cause false sheds. */
+/**
+ * Read pressure severity for admission decisions.
+ *
+ * This MUST drive an active re-sample (`checkResourcePressureGuard`), not a
+ * passive cache read of `getResourcePressureObservation`. The resource-pressure
+ * runtime only refreshes its sample and re-evaluates recovery from *inside*
+ * `check()` (via `scheduleRefresh`) — nothing else in the singleton mutates
+ * `state` or schedules a refresh. The structural admission gate that calls
+ * this function runs *before* every other code path that would otherwise call
+ * `check()` (`handleChatCore`, `checkResourcePressureBeforeProviderWork`,
+ * `AdaptiveAdmissionRuntimeImpl.acquire`) — so once `state.severity` flips to
+ * "critical", a passive read here sheds every subsequent request before any
+ * of those downstream paths can run, which means `check()` never gets called
+ * again and the guard can never observe recovery. See
+ * https://github.com/diegosouzapw/OmniRoute/issues/13821.
+ *
+ * `checkResourcePressureGuard()` is cheap on the hot path: it only does a
+ * synchronous `process.memoryUsage()` read plus a timestamp comparison per
+ * call; the actual signal sampling (`/proc/pressure/memory`, cgroup reads)
+ * happens asynchronously via `scheduleRefresh()` and is throttled by
+ * `staleAfterMs`, so calling this on every admitted request does not add
+ * per-request I/O.
+ *
+ * A non-null guard is this request's authoritative "shed now" answer and maps
+ * to "critical". A null guard means this request is not shed, but the
+ * observation's cached label can still read "critical" for a few more
+ * milliseconds until the async refresh settles (or if the last real sample
+ * merely went stale — `check()`'s own `maxStaleMs` fallback) — reporting that
+ * stale "critical" label to callers that branch on severity (e.g. the queue
+ * wait sizing at admitChatRequest's `reserve()`) would just re-introduce the
+ * same "never downgrades" problem for the "high" queueing bucket, so it is
+ * downgraded to "high" here instead.
+ */
export function defaultPressureSeverity(): PressureSeverity {
try {
- return getResourcePressureObservation().state.severity;
+ const guard = checkResourcePressureGuard();
+ if (guard) return "critical";
+ const severity = getResourcePressureObservation().state.severity;
+ return severity === "critical" ? "high" : severity;
} catch {
return "normal";
}
diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts
index 5e169ab9a0..3655ed885c 100644
--- a/src/shared/network/remoteImageFetch.ts
+++ b/src/shared/network/remoteImageFetch.ts
@@ -146,6 +146,16 @@ async function readResponseBuffer(response: Response, maxBytes: number) {
return Buffer.concat(chunks, totalBytes);
}
+// #13883: test-only escape hatch for `pinDns: true` callers that have no `fetchImpl` seam
+// of their own (imageGeneration.ts / imageUpscale/shared.ts). `createPinnedFetch` opens a
+// real undici connection, bypassing a test's monkeypatched `globalThis.fetch`; setting this
+// override lets such a test keep exercising its mock instead of a real network attempt.
+// Production callers never call the setter, so `pinDns` still pins for real in production.
+let pinnedFetchTestOverride: typeof fetch | undefined;
+export function setPinnedFetchTestOverride(fetchImpl: typeof fetch | undefined): void {
+ pinnedFetchTestOverride = fetchImpl;
+}
+
export async function fetchRemoteMedia(
input: string | URL,
options: RemoteMediaFetchOptions = {}
@@ -171,6 +181,7 @@ export async function fetchRemoteMedia(
const addresses = await assertHostnameResolvesPublic(currentUrl, guard, lookup);
const fetchImpl =
injectedFetch ??
+ pinnedFetchTestOverride ??
(pinDns && addresses.length
? createPinnedFetch(addresses[0].address, addresses[0].family)
: fetch);
diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts
index b100cbcc7f..713c823184 100644
--- a/src/shared/utils/apiKeyPolicy.ts
+++ b/src/shared/utils/apiKeyPolicy.ts
@@ -75,6 +75,7 @@ export interface ApiKeyMetadata {
name?: string;
modelAccessMode?: "all" | "restricted";
allowedModels?: string[];
+ blockedModels?: string[];
allowedCombos?: string[];
allowedConnections?: string[];
allowedQuotas?: string[];
@@ -346,6 +347,7 @@ async function validateStandardRoutingTarget(
const hasModelRestrictions =
apiKeyInfo.modelAccessMode === "restricted" ||
Boolean(apiKeyInfo.allowedModels?.length) ||
+ Boolean(apiKeyInfo.blockedModels?.length) ||
apiKeyInfo.disableNonPublicModels === true;
if (!requestedComboName && hasModelRestrictions && modelStr.startsWith("auto/")) {
requestedComboName = modelStr;
@@ -587,6 +589,7 @@ async function validateModelAccess(context: PolicyContext): Promise = [
/\bTPD rate limit\b/i,
/insufficient balance/i,
+ // xAI Grok Build free-tier per-model rolling 24h cap. Live body:
+ // "You've used all the included free usage for model grok-4.6 for now.
+ // Usage resets over a rolling 24-hour window — tokens (actual/limit): N/M."
+ /used all the included free usage/i,
+ /resets over a rolling 24-hour window/i,
+
// ── CJK quota-exhaustion patterns (#13194) ────────────────────────────
// Chinese (simplified) providers (z.ai/GLM, Kimi/Moonshot, Qwen/DashScope,
// MiniMax) return 429 bodies entirely in Chinese. Without these, the
diff --git a/src/shared/utils/resolveOmniRouteBaseUrl.ts b/src/shared/utils/resolveOmniRouteBaseUrl.ts
index 3f4c18f33b..45fa245792 100644
--- a/src/shared/utils/resolveOmniRouteBaseUrl.ts
+++ b/src/shared/utils/resolveOmniRouteBaseUrl.ts
@@ -4,6 +4,9 @@ type OmniRouteBaseUrlEnv = {
OMNIROUTE_BASE_URL?: string;
BASE_URL?: string;
NEXT_PUBLIC_BASE_URL?: string;
+ PORT?: string | number;
+ API_PORT?: string | number;
+ DASHBOARD_PORT?: string | number;
};
function normalizeBaseUrl(value?: string): string | null {
@@ -13,11 +16,14 @@ function normalizeBaseUrl(value?: string): string | null {
}
export function resolveOmniRouteBaseUrl(env: OmniRouteBaseUrlEnv = process.env): string {
+ const port = env.PORT || env.API_PORT || env.DASHBOARD_PORT;
+ const fallback = port ? `http://localhost:${port}` : DEFAULT_OMNIROUTE_BASE_URL;
+
return (
normalizeBaseUrl(env.OMNIROUTE_BASE_URL) ||
normalizeBaseUrl(env.BASE_URL) ||
normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) ||
- DEFAULT_OMNIROUTE_BASE_URL
+ fallback
);
}
diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts
index 306b8da498..51ed4072ae 100644
--- a/src/shared/validation/schemas/combo.ts
+++ b/src/shared/validation/schemas/combo.ts
@@ -432,8 +432,9 @@ export const updateComboSchema = z
// so the one endpoint a client can flip it through stripped the field and
// a visibility-only update was rejected as empty. #12836
isHidden: z.boolean().optional(),
- allowedProviders: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
- allowedModelFamilies: z.array(z.string().trim().min(1).max(100)).max(100).optional(),
+ allowedProviders: z.array(z.string().trim().min(1).max(200)).max(100).optional().nullable(),
+ allowedModelFamilies: z.array(z.string().trim().min(1).max(100)).max(100).optional().nullable(),
+ overrideAllowedProviders: z.boolean().optional(),
// Nullable like `description` and `context_length` above: an absent field means
// "leave unchanged" because updateCombo merges over the stored record, so clearing
// one needs an explicit null for updateCombo's null-means-delete pass (#12158).
diff --git a/src/shared/validation/schemas/keys.ts b/src/shared/validation/schemas/keys.ts
index 17e05bb75b..d16d7d3856 100644
--- a/src/shared/validation/schemas/keys.ts
+++ b/src/shared/validation/schemas/keys.ts
@@ -61,6 +61,7 @@ export const createKeySchema = z
dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(),
weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(),
chaosModeEnabled: z.boolean().optional(),
+ expiresAt: z.string().datetime().nullable().optional(),
scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(),
allowedConnections: z.array(z.string().uuid()).min(1).max(100).optional(),
})
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index cb69b10220..757916fda6 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -2411,7 +2411,13 @@ async function handleSingleModelChat(
const passthroughModels = credentials.providerSpecificData?.passthroughModels;
if (
result.status === 429 &&
- shouldMarkAccountExhaustedFrom429(provider, model, passthroughModels, failureKind) &&
+ shouldMarkAccountExhaustedFrom429(
+ provider,
+ model,
+ passthroughModels,
+ failureKind,
+ errorStr
+ ) &&
// T-PROBE: a probe must not poison the 5min quotaCache for real
// traffic (#9817).
!(await shouldIsolateProbeFailures())
diff --git a/src/sse/handlers/chat/comboTargetKeyPolicy.ts b/src/sse/handlers/chat/comboTargetKeyPolicy.ts
index 7bc441056c..81bc7f07cc 100644
--- a/src/sse/handlers/chat/comboTargetKeyPolicy.ts
+++ b/src/sse/handlers/chat/comboTargetKeyPolicy.ts
@@ -7,8 +7,11 @@
* inner target so #9057 holds.
*/
+import { isModelBlockedByPatterns } from "@/lib/db/apiKeys";
+
export type ComboTargetKeyPolicyInfo = {
allowedModels?: string[] | null;
+ blockedModels?: string[] | null;
disableNonPublicModels?: boolean | null;
modelAccessMode?: string | null;
};
@@ -37,9 +40,13 @@ export async function comboTargetPassesKeyModelPolicy(opts: {
if (!apiKey || !apiKeyInfo) return true;
const hasModelRestrictions =
- Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true;
+ Boolean(apiKeyInfo.allowedModels?.length) ||
+ Boolean(apiKeyInfo.blockedModels?.length) ||
+ apiKeyInfo.disableNonPublicModels === true;
if (!hasModelRestrictions) return true;
+ if (await isModelBlockedByPatterns(apiKeyInfo.blockedModels, targetModelStr)) return false;
+
if (allowListCoversRequestedCombo(apiKeyInfo.allowedModels, requestedModelStr)) {
return true;
}
diff --git a/src/sse/services/codexWsLease.ts b/src/sse/services/codexWsLease.ts
index 9ef1675fe3..0f91e3cb9a 100644
--- a/src/sse/services/codexWsLease.ts
+++ b/src/sse/services/codexWsLease.ts
@@ -18,7 +18,8 @@ export async function acquireCodexWsLease(
typeof configuredMaxConcurrent === "number" && configuredMaxConcurrent > 0
? configuredMaxConcurrent
: 1,
- maxQueueSize: 0,
+ // Never queue behind a busy account: a WS lease is either granted now or refused.
+ failFast: true,
});
const leaseId = randomUUID();
leases.set(leaseId, release);
diff --git a/tests/integration/_chatPipelineHarness.ts b/tests/integration/_chatPipelineHarness.ts
index c154255914..6c69d83c07 100644
--- a/tests/integration/_chatPipelineHarness.ts
+++ b/tests/integration/_chatPipelineHarness.ts
@@ -285,6 +285,16 @@ export async function createChatPipelineHarness(prefix) {
invalidateMemorySettingsCache();
clearSkillState();
await new Promise((resolve) => setTimeout(resolve, 20));
+ // Call-log persistence is fire-and-forget and the first cold artifact-worker
+ // spawn can take ~2.4s, so the previous test's saves may still be in flight.
+ // Drain before the DB reset so they land in the DB being torn down, not in the
+ // next test's fresh database (#12780).
+ const drained = await callLogsDb.waitForCallLogSaves(10_000);
+ if (!drained) {
+ console.warn(
+ `[chat-pipeline-harness:${prefix}] call-log saves did not drain within 10s; resetting anyway`
+ );
+ }
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(testDataDir, { recursive: true });
@@ -299,6 +309,7 @@ export async function createChatPipelineHarness(prefix) {
semanticCacheModule.clearCache();
clearSkillState();
resetAllCircuitBreakers();
+ await callLogsDb.waitForCallLogSaves(10_000);
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts
index 3620f1ebba..03b3e9e8a1 100644
--- a/tests/integration/chat-pipeline.test.ts
+++ b/tests/integration/chat-pipeline.test.ts
@@ -16,6 +16,7 @@ const settingsDb = await import("../../src/lib/db/settings.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const readCacheDb = await import("../../src/lib/db/readCache.ts");
const { getLatestCallLog, getResponsesCallLogs } = await import("./_chatPipelineCallLogs.ts");
+const { waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts");
const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts");
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
@@ -373,6 +374,15 @@ async function resetStorage() {
readCacheDb.invalidateDbCache();
invalidateMemorySettingsCache();
await new Promise((resolve) => setTimeout(resolve, 20));
+ // Call-log persistence is fire-and-forget (persistAttemptLogs → saveCallLog with
+ // a .catch(() => {})), and the first cold artifact-worker spawn can take ~2.4s, so
+ // the previous test's saves may still be in flight here. Draining before the DB
+ // reset keeps those rows in the DB being torn down instead of letting them land
+ // in the next test's fresh database (#12780).
+ const drained = await waitForCallLogSaves(10_000);
+ if (!drained) {
+ console.warn("[chat-pipeline] call-log saves did not drain within 10s; resetting anyway");
+ }
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
@@ -663,7 +673,13 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call
);
const json = (await response.json()) as any;
- const callLog = await waitFor(() => getLatestCallLog());
+ // Wait specifically for THIS request's Codex /v1/responses row instead of taking
+ // whatever the latest row happens to be: an unfiltered read can surface a row from
+ // a previous test that landed late in this database (#12780).
+ const callLog = await waitFor(async () => {
+ const rows = await getResponsesCallLogs();
+ return rows.find((row) => row.provider === "codex") ?? null;
+ });
assert.equal(response.status, 200);
assert.equal(fetchCalls.length, 1);
diff --git a/tests/integration/openrouter-reasoning-details-e2e.test.ts b/tests/integration/openrouter-reasoning-details-e2e.test.ts
new file mode 100644
index 0000000000..a127ef660f
--- /dev/null
+++ b/tests/integration/openrouter-reasoning-details-e2e.test.ts
@@ -0,0 +1,233 @@
+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-openrouter-reasoning-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.REQUIRE_API_KEY = "false";
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-openrouter-reasoning-secret";
+process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
+
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const { handleChat } = await import("../../src/sse/handlers/chat.ts");
+const { initTranslators } = await import("../../open-sse/translator/index.ts");
+const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
+const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
+const { resetAllCircuitBreakers } =
+ await import("../../src/shared/utils/circuitBreaker.ts");
+
+const originalFetch = globalThis.fetch;
+const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
+
+type FetchCall = {
+ url: string;
+ method?: string;
+ headers: Record;
+ body: Record | null;
+};
+
+function toPlainHeaders(headers: HeadersInit | undefined | null) {
+ if (!headers) return {};
+ if (headers instanceof Headers) return Object.fromEntries(headers.entries());
+ if (Array.isArray(headers)) return Object.fromEntries(headers);
+ return Object.fromEntries(
+ Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
+ );
+}
+
+function buildRequest(url: string, overrides: RequestInit = {}) {
+ const headers = new Headers({
+ "content-type": "application/json",
+ ...((overrides.headers as Record) || {}),
+ });
+ return new Request(url, { ...overrides, headers });
+}
+
+/**
+ * OpenRouter-shaped non-streaming completion: the provider returns BOTH a
+ * `reasoning` string AND a `reasoning_details[]` array carrying the same
+ * thinking text. This is exactly what DeepSeek V4 / GLM 5.3 / Kimi K3 return
+ * through OpenRouter (#12665).
+ */
+function buildOpenRouterStreamingSse({
+ thinking = "Hmm, let me think this through",
+ content = "Visible answer",
+} = {}) {
+ const chunk = (delta: Record) =>
+ `data: ${JSON.stringify({
+ id: "chatcmpl_openrouter_reasoning_stream",
+ object: "chat.completion.chunk",
+ created: 1783636289,
+ model: "deepseek/deepseek-v4-flash",
+ choices: [
+ { index: 0, delta, finish_reason: null, logprobs: null },
+ ],
+ })}\n\n`;
+ return (
+ chunk({ reasoning: thinking, reasoning_details: [{ type: "reasoning.text", text: thinking }] }) +
+ chunk({ content }) +
+ chunk({}) +
+ chunk({}) +
+ "data: [DONE]\n\n"
+ );
+}
+
+function buildOpenRouterResponse({
+ content = "Visible answer",
+ thinking = "Hmm, let me think this through",
+} = {}) {
+ return new Response(
+ JSON.stringify({
+ id: "chatcmpl_openrouter_reasoning",
+ object: "chat.completion",
+ created: 1783636289,
+ model: "deepseek/deepseek-v4-flash",
+ choices: [
+ {
+ index: 0,
+ message: {
+ role: "assistant",
+ content,
+ reasoning: thinking,
+ reasoning_details: [{ type: "reasoning.text", text: thinking }],
+ },
+ finish_reason: "stop",
+ logprobs: null,
+ },
+ ],
+ usage: { prompt_tokens: 20, completion_tokens: 30, total_tokens: 50 },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+}
+
+test.before(async () => {
+ await initTranslators();
+});
+
+test.afterEach(() => {
+ globalThis.fetch = originalFetch;
+ BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
+ BaseExecutor.freeze?.();
+ clearInflight();
+ resetAllCircuitBreakers();
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+});
+
+test("openrouter provider: reasoning_details[].text is mirrored to reasoning_content even when reasoning string is present", async () => {
+ await providersDb.createProviderConnection({
+ provider: "openrouter",
+ authType: "apikey",
+ name: "openrouter-reasoning-e2e",
+ apiKey: "sk-mock-openrouter-key",
+ isActive: true,
+ testStatus: "active",
+ providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" },
+ });
+
+ const fetchCalls: FetchCall[] = [];
+
+ globalThis.fetch = async (input, init: RequestInit = {}) => {
+ fetchCalls.push({
+ url: String(input),
+ method: init.method || "GET",
+ headers: toPlainHeaders(init.headers),
+ body: init.body ? JSON.parse(String(init.body)) : null,
+ });
+ return buildOpenRouterResponse();
+ };
+
+ const response = await handleChat(
+ buildRequest("http://localhost/v1/chat/completions", {
+ method: "POST",
+ body: JSON.stringify({
+ model: "openrouter/auto",
+ stream: false,
+ messages: [{ role: "user", content: "Think through this carefully." }],
+ }),
+ })
+ );
+
+ const json = (await response.json()) as {
+ choices: Array<{
+ message: {
+ content?: unknown;
+ reasoning?: unknown;
+ reasoning_content?: unknown;
+ reasoning_details?: unknown;
+ };
+ }>;
+ };
+
+ assert.equal(response.status, 200, JSON.stringify(json));
+ assert.equal(fetchCalls.length, 1, "should make exactly one upstream call");
+ assert.match(fetchCalls[0].url, /mock-openrouter\.invalid/, fetchCalls[0].url);
+
+ const message = json.choices[0].message;
+ assert.equal(message.content, "Visible answer");
+ // The client-readable field must be populated from reasoning_details[].text
+ // even though the `reasoning` alias is also present (#12665).
+ assert.equal(message.reasoning_content, "Hmm, let me think this through");
+ assert.equal(message.reasoning, "Hmm, let me think this through");
+ assert.deepEqual(message.reasoning_details, [
+ { type: "reasoning.text", text: "Hmm, let me think this through" },
+ ]);
+});
+
+test("openrouter provider: streaming deltas carry reasoning_content from reasoning_details[].text", async () => {
+ await providersDb.createProviderConnection({
+ provider: "openrouter",
+ authType: "apikey",
+ name: "openrouter-reasoning-stream-e2e",
+ apiKey: "sk-mock-openrouter-key",
+ isActive: true,
+ testStatus: "active",
+ providerSpecificData: { baseUrl: "http://mock-openrouter.invalid/v1" },
+ });
+
+ let fetched = false;
+ globalThis.fetch = async (input, init: RequestInit = {}) => {
+ void input;
+ void init;
+ fetched = true;
+ return new Response(buildOpenRouterStreamingSse(), {
+ status: 200,
+ headers: { "content-type": "text/event-stream; charset=utf-8" },
+ });
+ };
+
+ const response = await handleChat(
+ buildRequest("http://localhost/v1/chat/completions", {
+ method: "POST",
+ body: JSON.stringify({
+ model: "openrouter/auto",
+ stream: true,
+ messages: [{ role: "user", content: "Think through this carefully." }],
+ }),
+ })
+ );
+
+ const raw = await response.text();
+ assert.equal(response.status, 200, raw);
+ assert.equal(fetched, true, "should make exactly one upstream call");
+
+ const chunks = raw.split("\n\n").filter((line) => line.startsWith("data: "));
+ const payloads = chunks
+ .map((line) => line.replace(/^data: /, ""))
+ .filter((json) => json !== "[DONE]")
+ .map((json) => JSON.parse(json) as {
+ choices?: Array<{ delta?: Record }>;
+ });
+
+ const reasoningContentDeltas = payloads
+ .map((payload) => payload.choices?.[0]?.delta?.reasoning_content)
+ .filter((content): content is string => Boolean(content));
+
+ assert.equal(reasoningContentDeltas.length, 1, JSON.stringify(payloads));
+ assert.equal(reasoningContentDeltas[0], "Hmm, let me think this through");
+});
diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts
index d2693a5acf..0a7d5856fb 100644
--- a/tests/unit/account-fallback-service.test.ts
+++ b/tests/unit/account-fallback-service.test.ts
@@ -1196,6 +1196,22 @@ test("isCreditsExhausted returns true for actual credits-exhausted signals", ()
);
});
+test("isCreditsExhausted matches FriendliAI credit-exhaustion 403 body (#13040)", () => {
+ // FriendliAI returns HTTP 403 with body {"detail":"You've exhausted all your
+ // credits..."} when free tier credits are depleted via Adaptive Rate Limits.
+ // Before #13040 this fell through every quota/credits check to the generic
+ // 403 -> AUTH_ERROR fallback; the signal below routes it to QUOTA_EXHAUSTED.
+ assert.equal(isCreditsExhausted("You've exhausted all your credits"), true);
+ assert.equal(
+ isCreditsExhausted('{"detail":"You\'ve exhausted all your credits"}'),
+ true
+ );
+ assert.equal(
+ isCreditsExhausted("exhausted all your credits"),
+ true
+ );
+});
+
test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => {
// These patterns were removed because they falsely matched Gemini RPM 429 errors
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource has been exhausted"), false);
diff --git a/tests/unit/accountSemaphore.test.ts b/tests/unit/accountSemaphore.test.ts
index 92cd8ce5d2..0dcf268ba9 100644
--- a/tests/unit/accountSemaphore.test.ts
+++ b/tests/unit/accountSemaphore.test.ts
@@ -116,13 +116,13 @@ describe("accountSemaphore acquireMany", () => {
(await queued)();
});
- it("fails immediately when maxQueueSize is zero", async () => {
+ it("fails immediately when failFast is set", async () => {
const release = await acquire("codex:account-a", { maxConcurrency: 1 });
await assert.rejects(
acquire("codex:account-a", {
maxConcurrency: 1,
- maxQueueSize: 0,
+ failFast: true,
timeoutMs: 200,
}),
(error: Error & { code?: string }) => error.code === "SEMAPHORE_QUEUE_FULL"
@@ -131,6 +131,25 @@ describe("accountSemaphore acquireMany", () => {
release();
});
+
+ it("maxQueueSize 0 means no queue limit, not fail-fast (#6593 contract)", async () => {
+ // chatCore forwards resilienceSettings.requestQueue.maxQueueDepth, whose documented
+ // default is `0 = disabled`. #12911 briefly read 0 as "reject when busy", which
+ // turned every busy account slot into a 429 under default settings.
+ const release = await acquire("codex:account-b", { maxConcurrency: 1 });
+
+ const queued = acquire("codex:account-b", {
+ maxConcurrency: 1,
+ maxQueueSize: 0,
+ timeoutMs: 500,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(getStats()["codex:account-b"]?.queued ?? 0, 1, "must wait in the queue");
+
+ release();
+ const releaseQueued = await queued;
+ releaseQueued();
+ });
});
describe("accountSemaphore", async () => {
diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts
index 5f0ab5c0b4..9fdc2cfa1b 100644
--- a/tests/unit/antigravity-model-aliases.test.ts
+++ b/tests/unit/antigravity-model-aliases.test.ts
@@ -62,6 +62,10 @@ test("resolveAntigravityModelId maps the documented Antigravity aliases to upstr
}
assert.equal(resolveAntigravityModelId("gemini-3.7-flash"), "gemini-3.7-flash-tiered");
assert.equal(resolveAntigravityModelId("gemini-3.7-flash-tiered"), "gemini-3.7-flash-tiered");
+ assert.equal(resolveAntigravityModelId("gemini-3.8-flash"), "gemini-3.8-flash-high");
+ assert.equal(resolveAntigravityModelId("gemini-3.8-flash-high"), "gemini-3.8-flash-high");
+ assert.equal(resolveAntigravityModelId("gemini-3.8-flash-medium"), "gemini-3.8-flash-medium");
+ assert.equal(resolveAntigravityModelId("gemini-3.8-flash-low"), "gemini-3.8-flash-low");
assert.equal(resolveAntigravityModelId("gpt-oss-120b"), "gpt-oss-120b-medium");
assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6");
assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6");
diff --git a/tests/unit/antigravity-native-toolcall-collect.test.ts b/tests/unit/antigravity-native-toolcall-collect.test.ts
index a9c5bc8cc8..3be6203200 100644
--- a/tests/unit/antigravity-native-toolcall-collect.test.ts
+++ b/tests/unit/antigravity-native-toolcall-collect.test.ts
@@ -118,3 +118,27 @@ test("processAntigravitySSEPayload ignores a malformed functionCall without a na
assert.equal(collected.toolCalls.length, 0);
assert.equal(collected.textContent, "");
});
+
+test("processAntigravitySSEPayload collects text carrying thoughtSignature", () => {
+ const collected = emptyCollected();
+ processAntigravitySSEPayload(
+ JSON.stringify({
+ response: {
+ candidates: [
+ {
+ content: {
+ parts: [
+ { text: "internal reasoning", thought: true },
+ { text: "visible reply after tool execution", thoughtSignature: "sig-tool-res" },
+ ],
+ },
+ finishReason: "STOP",
+ },
+ ],
+ },
+ }),
+ collected
+ );
+
+ assert.equal(collected.textContent, "visible reply after tool execution");
+});
diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts
index 55885e3f2e..a46215b54a 100644
--- a/tests/unit/antigravity-retired-public-models.test.ts
+++ b/tests/unit/antigravity-retired-public-models.test.ts
@@ -36,6 +36,9 @@ const EXPECTED_LEADING_MODEL_ORDER = [
"gemini-3.7-flash-medium",
"gemini-3.7-flash-low",
"gemini-3.7-flash-tiered",
+ "gemini-3.8-flash-high",
+ "gemini-3.8-flash-medium",
+ "gemini-3.8-flash-low",
"gemini-pro-agent",
"gemini-3.1-pro-low",
"gemini-3.1-flash-lite",
diff --git a/tests/unit/antigravity-streaming-passthrough.test.ts b/tests/unit/antigravity-streaming-passthrough.test.ts
index 88702823b2..336c1527d8 100644
--- a/tests/unit/antigravity-streaming-passthrough.test.ts
+++ b/tests/unit/antigravity-streaming-passthrough.test.ts
@@ -36,6 +36,7 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const calls = [];
+ const telemetry: string[] = [];
seedAntigravityIdeVersionCache("2026.04.17-test");
seedAntigravityCliVersionCache("2026.04.17-test");
@@ -71,7 +72,13 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects
body: { request: { contents: [] } },
stream: false,
credentials: { accessToken: "token", projectId: "project-1" },
- log: { debug() {}, warn() {} },
+ log: {
+ debug(_scope, message) {
+ telemetry.push(String(message));
+ },
+ warn() {},
+ },
+ correlationId: "prompt194-native-retry-test",
});
// Non-streaming collects the upstream SSE and returns the already-converted
// OpenAI chat.completion payload — no further SSE parsing on the caller side.
@@ -79,6 +86,10 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects
assert.equal(payload.object, "chat.completion");
assert.equal(calls.length, 2);
+ const physicalSends = telemetry.filter((line) => line.includes("[Antigravity] PhysicalSend"));
+ assert.equal(physicalSends.length, calls.length);
+ assert.match(physicalSends[0] ?? "", /RequestId: prompt194-native-retry-test/);
+ assert.match(physicalSends[1] ?? "", /PhysicalSend: 2/);
assert.equal(result.response.status, 200);
assert.equal(payload.choices[0].message.content, "Hello again");
assert.deepEqual(payload.usage, {
diff --git a/tests/unit/api-key-create-expiry.test.ts b/tests/unit/api-key-create-expiry.test.ts
new file mode 100644
index 0000000000..cd26161e03
--- /dev/null
+++ b/tests/unit/api-key-create-expiry.test.ts
@@ -0,0 +1,129 @@
+// Regression tests: POST /api/keys (and createApiKey) must accept expiresAt
+// at creation time with identical semantics to the update path.
+//
+// Today createKeySchema strips expiresAt, so a key can only become expiring
+// via a second updateApiKeyPermissions call — leaving a window where the key
+// exists without expiry. Automation must be able to create an expiring key in
+// one operation.
+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";
+import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-create-expiry-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = "test-api-key-secret-create-expiry";
+
+const core = await import("../../src/lib/db/core.ts");
+const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
+const { createKeySchema } = await import("../../src/shared/validation/schemas/keys.ts");
+const listRoute = await import("../../src/app/api/keys/route.ts");
+
+const FUTURE = new Date(Date.now() + 60 * 60_000).toISOString();
+const PAST = new Date(Date.now() - 60_000).toISOString();
+
+async function resetStorage() {
+ delete process.env.INITIAL_PASSWORD;
+ core.resetDbInstance();
+ apiKeysDb.resetApiKeyState();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+async function enableManagementAuth() {
+ process.env.INITIAL_PASSWORD = "bootstrap-password";
+ const { updateSettings } = await import("@/lib/db/settings");
+ await updateSettings({ requireLogin: true, password: "" });
+}
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(async () => {
+ await resetStorage();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ core.resetDbInstance();
+});
+
+test("createKeySchema accepts expiresAt with update-path semantics", () => {
+ for (const expiresAt of [FUTURE, PAST, null, undefined]) {
+ const parsed = createKeySchema.safeParse({
+ name: "x",
+ ...(expiresAt !== undefined && { expiresAt }),
+ });
+ assert.equal(parsed.success, true, `expected expiresAt=${expiresAt} to parse`);
+ }
+ for (const expiresAt of ["not-a-date", 123, "", "2026-13-45"]) {
+ const parsed = createKeySchema.safeParse({ name: "x", expiresAt });
+ assert.equal(parsed.success, false, `expected expiresAt=${JSON.stringify(expiresAt)} to fail`);
+ }
+});
+
+test("createApiKey persists future expiresAt; key validates", async () => {
+ const created = await apiKeysDb.createApiKey("expiry-create", "machine-1", [], {
+ expiresAt: FUTURE,
+ });
+ const readback = await apiKeysDb.getApiKeyById(created.id);
+ assert.equal(readback?.expiresAt, FUTURE);
+ assert.equal(await apiKeysDb.validateApiKey(created.key), true);
+});
+
+test("createApiKey with past expiresAt is rejected like the update path", async () => {
+ const created = await apiKeysDb.createApiKey("expiry-past", "machine-1", [], { expiresAt: PAST });
+ assert.equal(await apiKeysDb.validateApiKey(created.key), false);
+});
+
+test("createApiKey without expiresAt stays non-expiring", async () => {
+ for (const opts of [{}, { expiresAt: null }, { expiresAt: undefined }]) {
+ const created = await apiKeysDb.createApiKey(
+ `expiry-absent-${JSON.stringify(opts.expiresAt)}`,
+ "machine-1",
+ [],
+ opts
+ );
+ const readback = await apiKeysDb.getApiKeyById(created.id);
+ assert.equal(readback?.expiresAt ?? null, null);
+ assert.equal(await apiKeysDb.validateApiKey(created.key), true);
+ }
+});
+
+test("update path still enforces expiry on a key created with future expiry", async () => {
+ const created = await apiKeysDb.createApiKey("expiry-interop", "machine-1", [], {
+ expiresAt: FUTURE,
+ });
+ assert.equal(await apiKeysDb.validateApiKey(created.key), true);
+ assert.equal(await apiKeysDb.updateApiKeyPermissions(created.id, { expiresAt: PAST }), true);
+ assert.equal(await apiKeysDb.validateApiKey(created.key), false);
+});
+
+test("POST /api/keys creates an expiring key in one operation", async () => {
+ await enableManagementAuth();
+ const response = await listRoute.POST(
+ await makeManagementSessionRequest("http://localhost/api/keys", {
+ method: "POST",
+ body: { name: "Route Expiry Key", expiresAt: FUTURE },
+ })
+ );
+ assert.equal(response.status, 201);
+ const body = (await response.json()) as { id: string; key: string; expiresAt: string | null };
+ assert.equal(body.expiresAt, FUTURE);
+ const readback = await apiKeysDb.getApiKeyById(body.id);
+ assert.equal(readback?.expiresAt, FUTURE);
+ assert.equal(await apiKeysDb.validateApiKey(body.key), true);
+});
+
+test("POST /api/keys rejects malformed expiresAt without creating a key", async () => {
+ await enableManagementAuth();
+ const before = (await apiKeysDb.getApiKeys()).length;
+ const response = await listRoute.POST(
+ await makeManagementSessionRequest("http://localhost/api/keys", {
+ method: "POST",
+ body: { name: "Bad Expiry Key", expiresAt: "not-a-date" },
+ })
+ );
+ assert.equal(response.status, 400);
+ assert.equal((await apiKeysDb.getApiKeys()).length, before);
+});
diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts
index eff769271f..e968d8bffa 100644
--- a/tests/unit/api-key-policy.test.ts
+++ b/tests/unit/api-key-policy.test.ts
@@ -517,6 +517,37 @@ test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", asyn
assert.match(await readErrorMessage(overBudget.rejection), /Daily budget exceeded/);
});
+test("enforceApiKeyPolicy applies blockedModels in all-access mode", async () => {
+ const key = await createKeyWithPolicy({
+ modelAccessMode: "all",
+ allowedModels: [],
+ blockedModels: ["gpt-6*", "*/gpt-6*"],
+ });
+ const policy = await loadPolicy("all-mode-blocked-models");
+
+ const blocked = await policy.enforceApiKeyPolicy(
+ makePolicyRequest(key.key),
+ "mbrouter/gpt-6-codex"
+ );
+ assert.equal(blocked.rejection.status, 403);
+
+ const allowed = await policy.enforceApiKeyPolicy(
+ makePolicyRequest(key.key),
+ "mbrouter/gpt-5.6-sol"
+ );
+ assert.equal(allowed.rejection, null);
+
+ const metadata = await apiKeysDb.getApiKeyMetadata(key.key);
+ assert.ok(metadata);
+ const rerouted = await policy.validateApiKeyRoutingTarget(
+ makePolicyRequest(key.key),
+ key.key,
+ metadata,
+ "gpt-6"
+ );
+ assert.equal(rerouted?.status, 403);
+});
+
test("enforceApiKeyPolicy returns Anthropic error envelope for /v1/messages model denials", async () => {
const restrictedKey = await createKeyWithPolicy({
allowedModels: ["cc/*"],
diff --git a/tests/unit/body-timeout-integration.test.ts b/tests/unit/body-timeout-integration.test.ts
index 87265b3c67..87ab8c5ab7 100644
--- a/tests/unit/body-timeout-integration.test.ts
+++ b/tests/unit/body-timeout-integration.test.ts
@@ -28,9 +28,11 @@ test("chatCore error classification maps BodyTimeoutError to 504 GATEWAY_TIMEOUT
// Read the source to verify the error classification logic includes BodyTimeoutError
const content = fs.readFileSync("open-sse/handlers/chatCore.ts", "utf8");
- // The error classification block should include BodyTimeoutError alongside TimeoutError
+ // The error classification block should include BodyTimeoutError alongside TimeoutError.
+ // Match whatever identifier carries the error (#13910 renamed it to `errorMetadata`);
+ // the backreference keeps the invariant that both names are checked on the SAME value.
const classificationPattern =
- /error\.name === ["']TimeoutError["']\s*\|\|\s*error\.name === ["']BodyTimeoutError["']/;
+ /(\w+)\.name === ["']TimeoutError["']\s*\|\|\s*\1\.name === ["']BodyTimeoutError["']/;
assert.ok(
classificationPattern.test(content),
"chatCore should classify BodyTimeoutError as GATEWAY_TIMEOUT (504)"
diff --git a/tests/unit/bug-12831.test.ts b/tests/unit/bug-12831.test.ts
new file mode 100644
index 0000000000..2a73c7a903
--- /dev/null
+++ b/tests/unit/bug-12831.test.ts
@@ -0,0 +1,90 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.ts";
+
+test("Issue #12831: fixes double-escaped tabs in Codex JSON tool call arguments", () => {
+ const events = [];
+ const emit = (_name, payload) => events.push(payload);
+ const state = {
+ responseId: "res_123",
+ funcCallIds: {},
+ funcNames: {},
+ funcArgsBuf: {},
+ funcArgsDone: {},
+ funcItemAdded: {},
+ funcItemDone: {},
+ msgItemAdded: {},
+ msgContentAdded: {},
+ msgTextBuf: {},
+ msgItemDone: {},
+ };
+
+ const chunk1 = {
+ choices: [
+ {
+ index: 0,
+ delta: {
+ tool_calls: [
+ {
+ index: 0,
+ id: "call_123",
+ function: {
+ name: "_edit",
+ // gpt-5.6-luna-xhigh emits literally \ followed by t in the JSON string
+ // to represent a tab, instead of a JSON escape for tab or a raw tab.
+ // Wait, in JSON, a tab in a string is encoded as "\t" (two characters: \ and t).
+ // If it's double-escaped, it emits "\t" (four characters: \, \, t in JSON string? No, two backslashes and a t: "\t")
+ // Let's assume the string is: {"input": "some code\twith tabs"}
+ arguments: '{\n "input": "some code\\twith tabs"',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const chunk2 = {
+ choices: [
+ {
+ index: 0,
+ delta: {
+ tool_calls: [
+ {
+ index: 0,
+ function: {
+ arguments: "\n}",
+ },
+ },
+ ],
+ },
+ finish_reason: "tool_calls",
+ },
+ ],
+ };
+
+ const chunk3 = {
+ usage: { prompt_tokens: 10, completion_tokens: 10 },
+ };
+
+ function processChunk(chunk) {
+ const chunkEvents = openaiToOpenAIResponsesResponse(chunk, state);
+ for (const ev of chunkEvents) {
+ emit(ev.event, ev.data);
+ }
+ }
+
+ processChunk(chunk1);
+ processChunk(chunk2);
+ processChunk(chunk3);
+
+ const doneEvent = events.find((e) => e.type === "response.function_call_arguments.done");
+
+ // Try parsing the arguments
+ const parsed = JSON.parse(doneEvent.arguments);
+ assert.strictEqual(
+ parsed.input,
+ "some code\twith tabs",
+ "The double-escaped tab should be unescaped to a single tab character"
+ );
+});
diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts
index 1d5a4eea21..fd6221adf2 100644
--- a/tests/unit/chatcore-translation-paths.test.ts
+++ b/tests/unit/chatcore-translation-paths.test.ts
@@ -379,6 +379,7 @@ async function invokeChatCore({
reasoningTransportFallback = "drop",
managedLease = null,
cachedSettings = null,
+ modelTargetFormat = undefined,
}: any = {}) {
const calls: any[] = [];
@@ -408,7 +409,10 @@ async function invokeChatCore({
const requestBody = structuredClone(body);
const result = await handleChatCore({
body: requestBody,
- modelInfo: { provider, model, extendedContext: false },
+ modelInfo:
+ modelTargetFormat !== undefined
+ ? { provider, model, extendedContext: false, targetFormat: modelTargetFormat }
+ : { provider, model, extendedContext: false },
credentials: credentials || {
apiKey: "sk-test",
// #13452/#13798: buildUrl() refuses an `*-compatible-*` node with no baseUrl
@@ -1565,6 +1569,65 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay
// user msg[2] (was clientMessages[3]): tool_result preserved (preserveToolResultBlocks:true)
assert.equal(call.body.messages[2].content[0].type, "tool_result");
});
+
+// Issue #13971: the CC-bridge unconditionally preserved raw tool_result blocks even when the
+// target speaks OpenAI-compatible (503 on those gateways). Fix: gate preserveToolResultBlocks
+// on targetFormat === FORMATS.CLAUDE. userAgent is plain (non-Claude-Code) so both requests hit
+// the CC-bridge's normalizeClaudeUpstreamMessages branch (chatCore.ts:2377-2385), not the
+// Claude-Code semantic-passthrough branch above it, which this fix does not touch.
+function ccBridgeToolResultCall(modelTargetFormat?: string) {
+ return invokeChatCore({
+ provider: "anthropic-compatible-cc-test",
+ model: "claude-sonnet-4-6",
+ endpoint: "/v1/messages",
+ credentials: {
+ apiKey: "sk-test",
+ providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" },
+ },
+ body: {
+ model: "claude-sonnet-4-6",
+ max_tokens: 64,
+ messages: [
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "toolu_x", name: "Read", input: {} }],
+ },
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "toolu_x", content: "file contents" }],
+ },
+ ],
+ tools: [{ name: "Read", input_schema: { type: "object", properties: {} } }],
+ },
+ userAgent: "unit-test",
+ responseFormat: "claude",
+ modelTargetFormat,
+ });
+}
+test("chatCore strips tool_result blocks on the CC-bridge path when the target is OpenAI-compatible", async () => {
+ const { call, result } = await ccBridgeToolResultCall("openai");
+ assert.equal(result.success, true);
+ // No block may be raw tool_result/tool_use — that shape 503'd on #13971; the
+ // orphan-tool-use cleanup also drops the now-unmatched assistant turn, a stronger guard.
+ for (const message of call.body.messages) {
+ for (const block of message.content) {
+ assert.notEqual(block.type, "tool_result");
+ assert.notEqual(block.type, "tool_use");
+ }
+ }
+ const flattened = call.body.messages
+ .flatMap((m: { content: Array<{ text?: string }> }) => m.content)
+ .map((b: { text?: string }) => b.text)
+ .join("\n");
+ assert.match(flattened, /file contents/);
+});
+// Same branch, real (Claude-native) target format — tool_result stays preserved raw.
+test("chatCore still preserves tool_result blocks on the CC-bridge path when the target is Claude-native", async () => {
+ const { call, result } = await ccBridgeToolResultCall();
+ assert.equal(result.success, true);
+ assert.equal(call.body.messages[0].content[0].type, "tool_use");
+ assert.equal(call.body.messages[1].content[0].type, "tool_result");
+});
test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
@@ -1832,6 +1895,40 @@ test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text bl
["hello"]
);
});
+// #13835: a third-party provider's own ordinary tool name (GitHub Copilot's client-executed
+// "web_fetch" function tool) must still get the proxy_ prefix even though this request lands
+// in the same general (non-claude-passthrough) branch as the "claude" provider test above —
+// only genuine first-party Anthropic traffic (provider "claude") should skip prefixing.
+test("chatCore still prefixes ordinary third-party tool names for non-Anthropic providers targeting Claude", async () => {
+ const { call } = await invokeChatCore({
+ provider: "github",
+ model: "claude-haiku-4.5",
+ endpoint: "/v1/chat/completions",
+ credentials: { apiKey: "gh-key", providerSpecificData: {} },
+ body: {
+ model: "github/claude-haiku-4.5",
+ messages: [{ role: "user", content: "fetch a url" }],
+ tools: [
+ {
+ type: "function",
+ function: {
+ name: "web_fetch",
+ description: "Fetches a URL from the internet.",
+ parameters: {
+ type: "object",
+ properties: { url: { type: "string" } },
+ required: ["url"],
+ },
+ },
+ },
+ ],
+ },
+ responseFormat: "claude",
+ });
+
+ assert.equal(call.body.tools[0].name, "proxy_web_fetch");
+ assert.equal(call.body._toolNameMap, undefined);
+});
test("chatCore restores prefixed Claude passthrough tool names in upstream responses", async () => {
const { result } = await invokeChatCore({
provider: "claude",
diff --git a/tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts b/tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts
new file mode 100644
index 0000000000..508cb7d264
--- /dev/null
+++ b/tests/unit/claude-passthrough-tool-name-mapless-leak.test.ts
@@ -0,0 +1,121 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { restoreClaudePassthroughToolUseName } from "../../open-sse/utils/stream.ts";
+
+/**
+ * #12721: a Claude SSE passthrough must never hand the client a tool_use name
+ * it did not declare. A mapless restoreClaudeToolName "upgrades" known Claude
+ * Code names (bash -> Bash), which breaks third-party Anthropic-format clients
+ * (pi/OpenCode on claude-format executors like devin-cli-agentic): tool
+ * dispatch fails client-side and the echoed history hard-fails with
+ * undeclared_historical_tool. Genuine Claude Code clients (declared PascalCase)
+ * must still be protected from OpenAI-style upstreams that downcase names
+ * (#7926).
+ */
+describe("restoreClaudePassthroughToolUseName — declared-casing normalization (#12721)", () => {
+ const anthropicTools = (names: string[]) =>
+ names.map((name) => ({
+ name,
+ description: "d",
+ input_schema: { type: "object", properties: {} },
+ }));
+ const toolUseBlock = (name: string) => ({
+ type: "content_block_start",
+ index: 0,
+ content_block: { type: "tool_use", id: "toolu_01", name, input: {} },
+ });
+
+ it("keeps a lowercase name verbatim when the client declared it lowercase (no map)", () => {
+ for (const name of ["bash", "read", "edit", "write", "grep", "glob"]) {
+ const parsed = toolUseBlock(name);
+ assert.equal(
+ restoreClaudePassthroughToolUseName(
+ parsed,
+ null,
+ anthropicTools(["bash", "read", "edit", "write", "grep", "glob"])
+ ),
+ false
+ );
+ assert.equal(parsed.content_block.name, name);
+ }
+ });
+
+ it("does NOT upgrade bash -> Bash when the client declared lowercase (no map) — the #12721 leak", () => {
+ const parsed = toolUseBlock("bash");
+ assert.equal(
+ restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])),
+ false
+ );
+ assert.equal(parsed.content_block.name, "bash");
+ });
+
+ it("downcases an upstream PascalCase echo back to the declared lowercase spelling (no map)", () => {
+ const parsed = toolUseBlock("Bash");
+ assert.equal(restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])), true);
+ assert.equal(parsed.content_block.name, "bash");
+ });
+
+ it("keeps Claude Code clients working: upstream downcase restored to declared PascalCase (#7926)", () => {
+ const parsed = toolUseBlock("bash");
+ assert.equal(restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["Bash"])), true);
+ assert.equal(parsed.content_block.name, "Bash");
+ });
+
+ it("prefers the alias map (renamed -> original) over declared casing", () => {
+ const parsed = toolUseBlock("Bash");
+ const map = new Map([["Bash", "bash"]]);
+ assert.equal(restoreClaudePassthroughToolUseName(parsed, map, anthropicTools(["Bash"])), true);
+ assert.equal(parsed.content_block.name, "bash");
+ });
+
+ it("proxy_ ledger (claude passthrough) must not trigger the canonical upgrade — the #12721 live leak", () => {
+ // buildClaudePassthroughToolNameMap always emits proxy_ ->
+ // for claude passthrough; a non-empty ledger used to route through
+ // restoreClaudeToolName whose canonical fallback upgraded bash -> Bash.
+ const parsed = toolUseBlock("bash");
+ const map = new Map([
+ ["proxy_bash", "bash"],
+ ["proxy_read", "read"],
+ ]);
+ assert.equal(
+ restoreClaudePassthroughToolUseName(parsed, map, anthropicTools(["bash", "read"])),
+ false
+ );
+ assert.equal(parsed.content_block.name, "bash");
+ });
+
+ it("proxy_ ledger still restores prefixed echoes", () => {
+ const parsed = toolUseBlock("proxy_bash");
+ const map = new Map([["proxy_bash", "bash"]]);
+ assert.equal(restoreClaudePassthroughToolUseName(parsed, map, anthropicTools(["bash"])), true);
+ assert.equal(parsed.content_block.name, "bash");
+ });
+
+ it("leaves undeclared names verbatim instead of canonicalizing them (no map)", () => {
+ const parsed = toolUseBlock("memory_store");
+ assert.equal(
+ restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])),
+ false
+ );
+ assert.equal(parsed.content_block.name, "memory_store");
+ });
+
+ it("reads OpenAI-style function.name declarations too", () => {
+ const parsed = toolUseBlock("bash");
+ const tools = [{ type: "function", function: { name: "bash", parameters: {} } }];
+ assert.equal(restoreClaudePassthroughToolUseName(parsed, null, tools), false);
+ assert.equal(parsed.content_block.name, "bash");
+ });
+
+ it("ignores non-tool_use blocks", () => {
+ const parsed = {
+ type: "content_block_start",
+ index: 0,
+ content_block: { type: "text", text: "hello" },
+ };
+ assert.equal(
+ restoreClaudePassthroughToolUseName(parsed, null, anthropicTools(["bash"])),
+ false
+ );
+ });
+});
diff --git a/tests/unit/cline-workos-auth-token-shape.test.ts b/tests/unit/cline-workos-auth-token-shape.test.ts
index 3982155b8f..fd76900cfa 100644
--- a/tests/unit/cline-workos-auth-token-shape.test.ts
+++ b/tests/unit/cline-workos-auth-token-shape.test.ts
@@ -138,3 +138,42 @@ test("DefaultExecutor labels internal health checks separately from user traffic
applyClineProtocolHeaders(headers, { taskId: headers["X-Task-ID"] });
assert.equal(headers["X-CLIENT-TYPE"], "omniroute-internal-health-check");
});
+
+test("DefaultExecutor handles dual-auth logging for clinepass provider", () => {
+ const executor = new DefaultExecutor("clinepass");
+
+ // API key auth mode
+ const apiKeyHeaders = executor.buildHeaders(
+ { apiKey: "sk-cline-123", authType: "apikey" },
+ true,
+ {}
+ );
+ assert.equal(apiKeyHeaders["Authorization"], "Bearer sk-cline-123");
+
+ // OAuth token auth mode — real OAuth credential shape (accessToken, not apiKey;
+ // see #11828 review) so this exercises the effectiveKey || credentials?.accessToken
+ // fallback that actually runs in production.
+ const oauthHeaders = executor.buildHeaders(
+ { accessToken: "workos_tok_456", authType: "oauth" },
+ true,
+ {}
+ );
+ assert.equal(oauthHeaders["Authorization"], "Bearer workos:workos_tok_456");
+});
+
+test("DefaultExecutor clinepass authType branch matches buildClinepassHeaders() directly (parity)", () => {
+ const executor = new DefaultExecutor("clinepass");
+
+ const apiKeyCredentials = { apiKey: "sk-cline-789", authType: "apikey" };
+ const oauthCredentials = { accessToken: "workos_tok_789", authType: "oauth" };
+
+ for (const credentials of [apiKeyCredentials, oauthCredentials]) {
+ const viaExecutor = executor.buildHeaders(credentials, true, {});
+ const viaDirectCall = buildClinepassHeaders(credentials, credentials.apiKey);
+ assert.equal(
+ viaExecutor["Authorization"],
+ viaDirectCall["Authorization"],
+ `Authorization mismatch for ${JSON.stringify(credentials)}`
+ );
+ }
+});
diff --git a/tests/unit/codex-client-headers.test.ts b/tests/unit/codex-client-headers.test.ts
new file mode 100644
index 0000000000..02db806724
--- /dev/null
+++ b/tests/unit/codex-client-headers.test.ts
@@ -0,0 +1,113 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { getCodexClientVersionFromHeaders } from "../../open-sse/config/codexClient.ts";
+import { CodexExecutor } from "../../open-sse/executors/codex.ts";
+
+test("getCodexClientVersionFromHeaders: extracts the version from a real Codex CLI User-Agent", () => {
+ assert.equal(
+ getCodexClientVersionFromHeaders({
+ "user-agent": "codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64)",
+ }),
+ "0.154.0"
+ );
+ assert.equal(
+ getCodexClientVersionFromHeaders({
+ "user-agent":
+ "codex_exec/0.154.0 (Mac OS 26.6.2; arm64) xterm-256color (codex_exec; 0.154.0)",
+ }),
+ "0.154.0"
+ );
+});
+
+test("getCodexClientVersionFromHeaders: prefers a valid generic version header over User-Agent", () => {
+ assert.equal(
+ getCodexClientVersionFromHeaders({
+ version: "9.9.9",
+ "user-agent": "codex_cli_rs/0.154.0 (Mac OS 26.6.2; arm64)",
+ }),
+ "9.9.9"
+ );
+});
+
+test("getCodexClientVersionFromHeaders: returns null when headers are absent or empty", () => {
+ assert.equal(getCodexClientVersionFromHeaders(null), null);
+ assert.equal(getCodexClientVersionFromHeaders(undefined), null);
+ assert.equal(getCodexClientVersionFromHeaders({}), null);
+});
+
+test("getCodexClientVersionFromHeaders: returns null for a non-Codex User-Agent with no version header", () => {
+ assert.equal(getCodexClientVersionFromHeaders({ "user-agent": "curl/8.4.0" }), null);
+});
+
+test("getCodexClientVersionFromHeaders: rejects a CRLF injection attempt in the version header", () => {
+ assert.equal(getCodexClientVersionFromHeaders({ version: "1.0.0\r\nX-Injected: evil" }), null);
+});
+
+test("getCodexClientVersionFromHeaders: rejects a version header longer than the 32-char safe token limit", () => {
+ const overlong = "1.0.0-" + "a".repeat(30);
+ assert.ok(overlong.length > 32);
+ assert.equal(getCodexClientVersionFromHeaders({ version: overlong }), null);
+});
+
+test("getCodexClientVersionFromHeaders: a CRLF/oversized User-Agent injection only ever yields the captured digits", () => {
+ assert.equal(
+ getCodexClientVersionFromHeaders({
+ "user-agent": "codex_cli_rs/1.0.0\r\nX-Evil: 1",
+ }),
+ "1.0.0"
+ );
+});
+
+test("CodexExecutor.buildHeaders forwards the caller's Codex client version from clientHeaders", () => {
+ const executor = new CodexExecutor();
+
+ const fromUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+ "user-agent": "codex_cli_rs/0.160.2 (Mac OS 26.6.2; arm64)",
+ });
+ assert.equal(fromUserAgent.Version, "0.160.2");
+ assert.equal(fromUserAgent["User-Agent"], "codex-cli/0.160.2 (Windows 10.0.26200; x64)");
+
+ const fromVersionHeader = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+ version: "9.9.9",
+ });
+ assert.equal(fromVersionHeader.Version, "9.9.9");
+ assert.equal(fromVersionHeader["User-Agent"], "codex-cli/9.9.9 (Windows 10.0.26200; x64)");
+});
+
+test("CodexExecutor.buildHeaders falls back to the default client version when clientHeaders is absent, empty, or unusable", () => {
+ const executor = new CodexExecutor();
+
+ const noHeaders = executor.buildHeaders({ accessToken: "codex-token" }, true);
+ assert.equal(noHeaders.Version, "0.153.4");
+
+ const emptyHeaders = executor.buildHeaders({ accessToken: "codex-token" }, true, {});
+ assert.equal(emptyHeaders.Version, "0.153.4");
+
+ const nonCodexUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+ "user-agent": "curl/8.4.0",
+ });
+ assert.equal(nonCodexUserAgent.Version, "0.153.4");
+});
+
+test("CodexExecutor.buildHeaders rejects injection attempts in the caller's version/User-Agent headers", () => {
+ const executor = new CodexExecutor();
+
+ const crlfVersion = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+ version: "1.0.0\r\nX-Injected: evil",
+ });
+ assert.equal(crlfVersion.Version, "0.153.4");
+ assert.equal(crlfVersion["User-Agent"].includes("\r\n"), false);
+
+ const overlongVersion = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+ version: "1.0.0-" + "a".repeat(30),
+ });
+ assert.equal(overlongVersion.Version, "0.153.4");
+
+ const injectedUserAgent = executor.buildHeaders({ accessToken: "codex-token" }, true, {
+ "user-agent": "codex_cli_rs/1.0.0\r\nX-Evil: 1",
+ });
+ assert.equal(injectedUserAgent.Version, "1.0.0");
+ assert.equal(injectedUserAgent["User-Agent"].includes("\r\n"), false);
+ assert.equal(injectedUserAgent["User-Agent"], "codex-cli/1.0.0 (Windows 10.0.26200; x64)");
+});
diff --git a/tests/unit/codex-effort-model-echo-3697.test.ts b/tests/unit/codex-effort-model-echo-3697.test.ts
index 0779322459..3f2207fb7f 100644
--- a/tests/unit/codex-effort-model-echo-3697.test.ts
+++ b/tests/unit/codex-effort-model-echo-3697.test.ts
@@ -7,9 +7,8 @@ import {
echoModelInSseLine,
} from "../../open-sse/services/responseModelEcho.ts";
-const { openaiToOpenAIResponsesResponse } = await import(
- "../../open-sse/translator/response/openai-responses.ts"
-);
+const { openaiToOpenAIResponsesResponse } =
+ await import("../../open-sse/translator/response/openai-responses.ts");
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
@@ -82,6 +81,68 @@ test("OpenAI -> Responses translator omits model when the upstream never sent on
assert.equal("model" in (completed!.data.response as Record), false);
});
+test("OpenAI -> Responses translator emits response.in_progress with output: [], background: false, error: null", () => {
+ const events = collectResponsesEvents([
+ {
+ id: "chatcmpl-1",
+ model: "gpt-5.5",
+ choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }],
+ },
+ null,
+ ]);
+
+ const inProgress = events.find((e) => e.event === "response.in_progress");
+ assert.ok(inProgress, "response.in_progress must be emitted");
+ const resp = inProgress!.data.response as Record;
+ assert.ok(Array.isArray(resp.output), "output must be an array");
+ assert.deepEqual(resp.output, []);
+ assert.equal(resp.background, false);
+ assert.equal(resp.error, null);
+
+ const addedItem = events.find((e) => e.event === "response.output_item.added")?.data
+ .item as Record;
+ assert.ok(addedItem, "output_item.added must exist");
+ assert.equal(addedItem.status, "in_progress");
+
+ const completed = events.find((e) => e.event === "response.completed")?.data.response as Record<
+ string,
+ unknown
+ >;
+ assert.ok(completed, "response.completed must exist");
+ const completedOutput = completed.output as Array>;
+ assert.equal(completedOutput[0].status, "completed");
+});
+
+test("OpenAI -> Responses translator always populates input_tokens_details and output_tokens_details", () => {
+ const events = collectResponsesEvents([
+ {
+ id: "chatcmpl-1",
+ model: "gemini-3.8-flash",
+ choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }],
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
+ },
+ {
+ id: "chatcmpl-1",
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
+ },
+ null,
+ ]);
+
+ const completed = events.find((e) => e.event === "response.completed")?.data?.response as Record<
+ string,
+ unknown
+ >;
+ assert.ok(completed.usage, "usage must be present");
+ const usage = completed.usage as Record;
+ assert.equal(usage.input_tokens, 10);
+ assert.equal(usage.output_tokens, 5);
+ assert.equal(usage.total_tokens, 15);
+ assert.ok(usage.input_tokens_details, "input_tokens_details must be present");
+ assert.ok(usage.output_tokens_details, "output_tokens_details must be present");
+ assert.deepEqual(usage.input_tokens_details, { cached_tokens: 0 });
+ assert.deepEqual(usage.output_tokens_details, { reasoning_tokens: 0 });
+});
+
test("full shim pipeline: bare upstream model in Responses payloads gets rewritten to the requested effort-suffixed id", () => {
const events = collectResponsesEvents([
{
diff --git a/tests/unit/combo-put-route-allowed-providers.test.ts b/tests/unit/combo-put-route-allowed-providers.test.ts
new file mode 100644
index 0000000000..0fa837ce0c
--- /dev/null
+++ b/tests/unit/combo-put-route-allowed-providers.test.ts
@@ -0,0 +1,85 @@
+// #13951 — route-level regression coverage for the PUT /api/combos/[id]
+// overrideAllowedProviders sync path. tests/unit/combo-update-invariants.test.ts
+// only exercises combosDb.updateCombo() directly, bypassing the PUT route
+// branch this test targets.
+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-put-route-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+const comboRoute = await import("../../src/app/api/combos/[id]/route.ts");
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+});
+
+function put(id: string, body: Record) {
+ return new Request(`http://localhost/api/combos/${id}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+test("PUT with overrideAllowedProviders on a combo with NO prior restriction stays unrestricted", async () => {
+ const combo = await combosDb.createCombo({
+ name: "unrestricted-combo",
+ strategy: "priority",
+ models: [{ provider: "claude", model: "claude-sonnet-5" }],
+ });
+ assert.ok(combo?.id);
+ assert.equal((combo as { allowedProviders?: string[] }).allowedProviders, undefined);
+
+ const response = await comboRoute.PUT(
+ put(combo.id, {
+ name: "unrestricted-combo",
+ models: [
+ { provider: "claude", model: "claude-sonnet-5" },
+ { provider: "openai", model: "gpt-5" },
+ ],
+ overrideAllowedProviders: true,
+ }),
+ { params: Promise.resolve({ id: combo.id }) }
+ );
+ assert.equal(response.status, 200);
+
+ const stored = (await combosDb.getComboById(combo.id)) as { allowedProviders?: string[] };
+ // The combo had no restriction before the edit — it must still have none
+ // afterwards. Synthesizing allowedProviders=["claude","openai"] here would
+ // be the #13951 regression: a later add-a-provider update would start
+ // failing COMBO_008 where it previously succeeded.
+ assert.equal(stored.allowedProviders, undefined);
+});
+
+test("PUT with overrideAllowedProviders on a combo with an EXISTING restriction unions the new step providers", async () => {
+ const combo = await combosDb.createCombo({
+ name: "restricted-combo",
+ strategy: "priority",
+ allowedProviders: ["claude"],
+ models: [{ provider: "claude", model: "claude-sonnet-5" }],
+ });
+ assert.ok(combo?.id);
+
+ const response = await comboRoute.PUT(
+ put(combo.id, {
+ name: "restricted-combo",
+ models: [
+ { provider: "claude", model: "claude-sonnet-5" },
+ { provider: "openai", model: "gpt-5" },
+ ],
+ overrideAllowedProviders: true,
+ }),
+ { params: Promise.resolve({ id: combo.id }) }
+ );
+ assert.equal(response.status, 200);
+
+ const stored = (await combosDb.getComboById(combo.id)) as { allowedProviders?: string[] };
+ assert.deepEqual([...(stored.allowedProviders ?? [])].sort(), ["claude", "openai"]);
+});
diff --git a/tests/unit/combo-restricted-key-target-policy-12886.test.ts b/tests/unit/combo-restricted-key-target-policy-12886.test.ts
index 2c6635e05c..ebe288122f 100644
--- a/tests/unit/combo-restricted-key-target-policy-12886.test.ts
+++ b/tests/unit/combo-restricted-key-target-policy-12886.test.ts
@@ -77,3 +77,50 @@ test("#12886: unrestricted key skips the gate", async () => {
assert.equal(ok, true);
assert.equal(called, 0);
});
+
+test("blockedModels still filters combo targets in all-access mode", async () => {
+ let called = 0;
+ const ok = await comboTargetPassesKeyModelPolicy({
+ apiKey: KEY,
+ apiKeyInfo: {
+ modelAccessMode: "all",
+ allowedModels: [],
+ blockedModels: ["deepseek/*"],
+ },
+ requestedModelStr: COMBO,
+ targetModelStr: INNER,
+ isModelAllowedForKey: async () => {
+ called += 1;
+ return false;
+ },
+ });
+ assert.equal(ok, false);
+ assert.equal(called, 0);
+});
+
+test("blockedModels takes precedence without disabling allowed combo targets", async () => {
+ const apiKeyInfo = {
+ modelAccessMode: "restricted",
+ allowedModels: [COMBO],
+ blockedModels: ["anthropic/*"],
+ };
+ const checker = allowListChecker([COMBO]);
+
+ const allowed = await comboTargetPassesKeyModelPolicy({
+ apiKey: KEY,
+ apiKeyInfo,
+ requestedModelStr: COMBO,
+ targetModelStr: INNER,
+ isModelAllowedForKey: checker,
+ });
+ const blocked = await comboTargetPassesKeyModelPolicy({
+ apiKey: KEY,
+ apiKeyInfo,
+ requestedModelStr: COMBO,
+ targetModelStr: OTHER,
+ isModelAllowedForKey: checker,
+ });
+
+ assert.equal(allowed, true);
+ assert.equal(blocked, false);
+});
diff --git a/tests/unit/combo-update-invariants.test.ts b/tests/unit/combo-update-invariants.test.ts
new file mode 100644
index 0000000000..342b397e69
--- /dev/null
+++ b/tests/unit/combo-update-invariants.test.ts
@@ -0,0 +1,89 @@
+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";
+import { updateComboSchema } from "../../src/shared/validation/schemas/combo.ts";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-invariants-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const combosDb = await import("../../src/lib/db/combos.ts");
+
+async function resetStorage() {
+ core.resetDbInstance();
+ if (fs.existsSync(TEST_DATA_DIR)) {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ }
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(async () => {
+ core.resetDbInstance();
+ if (fs.existsSync(TEST_DATA_DIR)) {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ }
+});
+
+test("updateComboSchema accepts nullable allowedProviders, allowedModelFamilies, and overrideAllowedProviders", () => {
+ const parsedNulls = updateComboSchema.safeParse({
+ allowedProviders: null,
+ allowedModelFamilies: null,
+ overrideAllowedProviders: true,
+ });
+ assert.equal(parsedNulls.success, true);
+ if (parsedNulls.success) {
+ assert.equal(parsedNulls.data.allowedProviders, null);
+ assert.equal(parsedNulls.data.allowedModelFamilies, null);
+ assert.equal(parsedNulls.data.overrideAllowedProviders, true);
+ }
+
+ const parsedArray = updateComboSchema.safeParse({
+ allowedProviders: ["claude", "antigravity"],
+ allowedModelFamilies: ["claude"],
+ });
+ assert.equal(parsedArray.success, true);
+});
+
+test("updateCombo allows updating allowedProviders and clearing with null", async () => {
+ const combo = await combosDb.createCombo({
+ name: "claude-combo",
+ allowedProviders: ["claude"],
+ models: [{ provider: "claude", model: "claude-sonnet-5" }],
+ });
+ assert.ok(combo?.id);
+
+ // Updating models to include a new provider with expanded allowedProviders succeeds
+ const updated = await combosDb.updateCombo(String(combo.id), {
+ allowedProviders: ["claude", "antigravity"],
+ models: [
+ { provider: "claude", model: "claude-sonnet-5" },
+ { provider: "antigravity", model: "claude-sonnet-4-6" },
+ ],
+ });
+ assert.ok(updated);
+ const typedUpdated = updated as {
+ allowedProviders?: string[];
+ models: Array<{ providerId?: string }>;
+ };
+ assert.deepEqual(typedUpdated.allowedProviders, ["claude", "antigravity"]);
+ assert.equal(typedUpdated.models.length, 2);
+
+ // Clearing allowedProviders with null succeeds and removes the invariant restriction
+ const cleared = await combosDb.updateCombo(String(combo.id), {
+ allowedProviders: null,
+ models: [{ provider: "openrouter", model: "nvidia/nemotron-3.5-lightning:free" }],
+ });
+ assert.ok(cleared);
+ const typedCleared = cleared as {
+ allowedProviders?: string[];
+ models: Array<{ providerId?: string }>;
+ };
+ assert.equal(typedCleared.allowedProviders, undefined);
+ assert.equal(typedCleared.models[0]?.providerId, "openrouter");
+});
diff --git a/tests/unit/combo/image-combo-empty-200-fallback.test.ts b/tests/unit/combo/image-combo-empty-200-fallback.test.ts
new file mode 100644
index 0000000000..bed58ae22e
--- /dev/null
+++ b/tests/unit/combo/image-combo-empty-200-fallback.test.ts
@@ -0,0 +1,328 @@
+/**
+ * Image combo fallback on empty 2xx upstream responses
+ *
+ * Repro: an OpenAI-compatible image provider (e.g. openrouter/*) can return
+ * HTTP 200 with an empty or malformed image payload (no usable b64_json/url in
+ * data[]). fetchImageEndpoint() used to normalize that to success:true, so
+ * executeImageCombo() stopped on the first leg and the client received an
+ * image-less 200. Hermes then rejected the response.
+ *
+ * Fix: require at least one usable image item before declaring success; an
+ * empty 2xx becomes a retryable 502 so the combo advances to the next leg.
+ *
+ * Strategy: real isolated SQLite DATA_DIR + real combo resolution + real
+ * credentials path (seeded apikey connection) + stubbed globalThis.fetch.
+ * No paid requests, no module mocking (tsx loader cannot mock ESM exports).
+ *
+ * Run: node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts
+ * --import ./tests/_setup/isolateDataDir.ts --test
+ * tests/unit/combo/image-combo-empty-200-fallback.test.ts
+ */
+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-image-combo-empty-200-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.JWT_SECRET = "test-jwt-secret-for-image-combo-empty-200-tests";
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-combo-empty-200-test-secret";
+
+const core = await import("@/lib/db/core.ts");
+const providersDb = await import("@/lib/db/providers.ts");
+const { createCombo } = await import("@/lib/db/combos");
+const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo");
+
+const PNG_B64 = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64");
+
+const originalFetch = globalThis.fetch;
+
+type LogEntry = { level: string; tag: unknown; msg: unknown };
+
+function createLog() {
+ const entries: LogEntry[] = [];
+ const record =
+ (level: string) =>
+ (tag: unknown, msg: unknown): number =>
+ entries.push({ level, tag, msg });
+ return {
+ info: record("info"),
+ warn: record("warn"),
+ error: record("error"),
+ debug: record("debug"),
+ entries,
+ };
+}
+
+function createMockAuth() {
+ return {
+ request: new Request("http://localhost:20128/v1/images/generations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "empty-200-combo", prompt: "a cat" }),
+ }),
+ policy: { apiKeyInfo: { id: "test-key", name: "test-key" } },
+ };
+}
+
+async function resetStorage() {
+ globalThis.fetch = originalFetch;
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+/** Seed an active openrouter apikey connection so credential resolution succeeds. */
+async function seedOpenRouterConnection() {
+ return providersDb.createProviderConnection({
+ provider: "openrouter",
+ authType: "apikey",
+ name: "openrouter-empty-200-test",
+ apiKey: "sk-or-test-not-a-real-key",
+ isActive: true,
+ testStatus: "active",
+ rateLimitedUntil: null,
+ });
+}
+
+/**
+ * Seed the pmoc-image-text style two-leg combo: first leg returns an empty
+ * 200 (stubbed upstream), second leg returns a valid image.
+ */
+async function seedTwoLegCombo(name: string) {
+ return createCombo({
+ name,
+ strategy: "priority",
+ models: ["openrouter/openai/gpt-5-image-mini", "openrouter/openai/gpt-5.4-image-2"],
+ });
+}
+
+/** Stub fetch: first call → empty 200, subsequent calls → valid image 200. */
+function stubFetchEmptyThenValid(hitLog: Array<{ url: string; model?: string }>) {
+ let callIndex = 0;
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
+ const index = callIndex++;
+ const bodyText = typeof init?.body === "string" ? init.body : String(init?.body ?? "");
+ let model: string | undefined;
+ try {
+ model = (JSON.parse(bodyText) as { model?: string }).model;
+ } catch {
+ model = undefined;
+ }
+ hitLog.push({ url: String(url), model });
+ if (index === 0) {
+ return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return new Response(
+ JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [{ b64_json: PNG_B64 }] }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ }) as typeof fetch;
+}
+
+/** Stub fetch: every call → empty 200 (all legs unusable). */
+function stubFetchAlwaysEmpty() {
+ globalThis.fetch = (async () =>
+ new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })) as typeof fetch;
+}
+
+/** Stub fetch: every call → single-leg valid image (direct-model baseline). */
+function stubFetchAlwaysValid(hitLog?: Array<{ url: string; model?: string }>) {
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
+ if (hitLog) {
+ let model: string | undefined;
+ try {
+ model = (
+ JSON.parse(typeof init?.body === "string" ? init.body : String(init?.body ?? "")) as {
+ model?: string;
+ }
+ ).model;
+ } catch {
+ model = undefined;
+ }
+ hitLog.push({ url: String(url), model });
+ }
+ return new Response(
+ JSON.stringify({ created: Math.floor(Date.now() / 1000), data: [{ b64_json: PNG_B64 }] }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ }) as typeof fetch;
+}
+
+test("empty 200 from first leg falls back to second leg and second leg image is served", async () => {
+ await resetStorage();
+ await seedOpenRouterConnection();
+ await seedTwoLegCombo("empty-200-combo");
+
+ const hits: Array<{ url: string; model?: string }> = [];
+ stubFetchEmptyThenValid(hits);
+
+ const log = createLog();
+ const response = await executeImageCombo(
+ "empty-200-combo",
+ { model: "empty-200-combo", prompt: "a cat", n: 1 },
+ createMockAuth(),
+ Date.now(),
+ log
+ );
+
+ assert.equal(response.status, 200, "combo must ultimately succeed via leg 2");
+ const body = (await response.json()) as { data?: Array<{ b64_json?: string }> };
+ assert.ok(Array.isArray(body.data), "response body must carry the image items array");
+ assert.equal(body.data?.length, 1, "exactly one image (from the second leg)");
+ assert.equal(body.data?.[0]?.b64_json, PNG_B64, "served image must come from leg 2");
+
+ // Both legs were tried: first the empty-200 stub, then the valid stub.
+ assert.equal(hits.length, 2, "combo must advance to the second leg");
+ assert.equal(hits[0].model, "openai/gpt-5-image-mini", "leg 1 model hit first");
+ assert.equal(hits[1].model, "openai/gpt-5.4-image-2", "leg 2 model hit second");
+
+ const warnJoined = log.entries
+ .filter((e) => e.level === "warn")
+ .map((e) => String(e.msg))
+ .join(" ");
+ assert.ok(
+ warnJoined.includes("without a usable image payload"),
+ "leg-1 empty 200 must be logged as unusable payload"
+ );
+});
+
+test("fallback metadata reflects the additional attempt via X-OmniRoute-Fallback-Attempts", async () => {
+ await resetStorage();
+ await seedOpenRouterConnection();
+ await seedTwoLegCombo("empty-200-fallback-meta-combo");
+
+ const hits: Array<{ url: string; model?: string }> = [];
+ stubFetchEmptyThenValid(hits);
+
+ const response = await executeImageCombo(
+ "empty-200-fallback-meta-combo",
+ { model: "empty-200-fallback-meta-combo", prompt: "a cat", n: 1 },
+ createMockAuth(),
+ Date.now(),
+ createLog()
+ );
+
+ assert.equal(response.status, 200);
+ const attempts = response.headers.get("X-OmniRoute-Fallback-Attempts");
+ assert.ok(attempts !== null, "fallback attempts header must be present");
+ assert.equal(attempts, "1", "one leg failed over, so fallback attempts must be 1");
+});
+
+test("valid first-leg response does not invoke later legs", async () => {
+ await resetStorage();
+ await seedOpenRouterConnection();
+ await seedTwoLegCombo("empty-200-valid-first-combo");
+
+ const hits: Array<{ url: string; model?: string }> = [];
+ stubFetchAlwaysValid(hits);
+
+ const response = await executeImageCombo(
+ "empty-200-valid-first-combo",
+ { model: "empty-200-valid-first-combo", prompt: "a cat", n: 1 },
+ createMockAuth(),
+ Date.now(),
+ createLog()
+ );
+
+ assert.equal(response.status, 200);
+ const body = (await response.json()) as { data?: Array<{ b64_json?: string }> };
+ assert.equal(body.data?.[0]?.b64_json, PNG_B64);
+ assert.equal(hits.length, 1, "first leg success must stop the combo (no later legs hit)");
+ assert.equal(hits[0].model, "openai/gpt-5-image-mini");
+});
+
+test("all legs returning empty 200 yields a retryable 502 with sanitized error", async () => {
+ await resetStorage();
+ await seedOpenRouterConnection();
+ await createCombo({
+ name: "empty-200-all-legs-combo",
+ strategy: "priority",
+ models: ["openrouter/openai/gpt-5-image-mini", "openrouter/openai/gpt-5.4-image-2"],
+ });
+
+ stubFetchAlwaysEmpty();
+
+ const response = await executeImageCombo(
+ "empty-200-all-legs-combo",
+ { model: "empty-200-all-legs-combo", prompt: "a cat", n: 1 },
+ createMockAuth(),
+ Date.now(),
+ createLog()
+ );
+
+ assert.equal(response.status, 502, "exhausted combo must surface the retryable 502");
+ const bodyStr = JSON.stringify(await response.json());
+ assert.ok(
+ bodyStr.includes("image payload") || bodyStr.includes("Image provider"),
+ "error must describe the unusable payload"
+ );
+ assert.ok(!bodyStr.includes("sk-or-test"), "error must not leak credentials");
+ assert.ok(!bodyStr.includes("at "), "error must not leak stack traces");
+});
+
+test("direct image model request with empty 200 is a retryable 502 (behavior preserved for valid payloads)", async () => {
+ await resetStorage();
+ await seedOpenRouterConnection();
+
+ const { handleImageGeneration } = await import("@omniroute/open-sse/handlers/imageGeneration");
+
+ // Empty 200 → retryable 502 (previously a bogus success)
+ stubFetchAlwaysEmpty();
+ const emptyResult = (await handleImageGeneration({
+ body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 },
+ credentials: { apiKey: "sk-or-test-not-a-real-key" },
+ log: createLog(),
+ })) as { success: boolean; status?: number; error?: string };
+ assert.equal(emptyResult.success, false);
+ assert.equal(emptyResult.status, 502);
+ assert.ok(
+ typeof emptyResult.error === "string" && !emptyResult.error.includes("sk-or-test"),
+ "sanitized error must not include credentials"
+ );
+
+ // Valid 200 → success preserved
+ stubFetchAlwaysValid();
+ const validResult = (await handleImageGeneration({
+ body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 },
+ credentials: { apiKey: "sk-or-test-not-a-real-key" },
+ log: createLog(),
+ })) as { success: boolean; data?: { data?: Array<{ b64_json?: string }> } };
+ assert.equal(validResult.success, true, "valid payload must still succeed");
+ assert.equal(validResult.data?.data?.[0]?.b64_json, PNG_B64);
+
+ // url-style payload → success preserved
+ globalThis.fetch = (async () =>
+ new Response(JSON.stringify({ created: 1, data: [{ url: "https://example.test/img.png" }] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })) as typeof fetch;
+ const urlResult = (await handleImageGeneration({
+ body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 },
+ credentials: { apiKey: "sk-or-test-not-a-real-key" },
+ log: createLog(),
+ })) as { success: boolean; data?: { data?: Array<{ url?: string }> } };
+ assert.equal(urlResult.success, true, "url-bearing payload must still succeed");
+ assert.equal(urlResult.data?.data?.[0]?.url, "https://example.test/img.png");
+
+ // Malformed: 200 with non-array data → retryable 502
+ globalThis.fetch = (async () =>
+ new Response(JSON.stringify({ created: 1, data: "not-an-array" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })) as typeof fetch;
+ const malformed = (await handleImageGeneration({
+ body: { model: "openrouter/openai/gpt-5-image-mini", prompt: "a cat", n: 1 },
+ credentials: { apiKey: "sk-or-test-not-a-real-key" },
+ log: createLog(),
+ })) as { success: boolean; status?: number };
+ assert.equal(malformed.success, false);
+ assert.equal(malformed.status, 502);
+});
diff --git a/tests/unit/dashboard/payload-section-size-limit-notice.test.tsx b/tests/unit/dashboard/payload-section-size-limit-notice.test.tsx
new file mode 100644
index 0000000000..42510e1c10
--- /dev/null
+++ b/tests/unit/dashboard/payload-section-size-limit-notice.test.tsx
@@ -0,0 +1,134 @@
+// @vitest-environment jsdom
+//
+// Regression guard for #13894: a size-limited call-log artifact does not
+// simply drop a payload — callLogArtifacts.ts writes an explicit marker in
+// its place (`{ error: { _omniroute_truncated: true, reason: ... } }` for the
+// pipeline, or the `[omitted: call log artifact size limit exceeded]` string
+// for requestBody/responseBody). Before this fix, RequestLoggerDetail fed
+// that marker straight into the generic JSON/`` renderer under a
+// generically-titled "Pipeline Error" section, so a size-limit omission was
+// silently indistinguishable from a real upstream error. PayloadSection must
+// now render an explicit, labeled notice instead whenever `notice` is set,
+// and buildPipelinePayloadSections()/isBodySizeLimitOmission() must detect
+// the marker shapes and set it.
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+vi.mock("@/shared/hooks/useTheme", () => ({
+ useTheme: () => ({ isDark: false }),
+}));
+
+const { PayloadSection, buildPipelinePayloadSections, isBodySizeLimitOmission } =
+ await import("../../../src/shared/components/RequestLoggerDetail.sections.tsx");
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+});
+
+describe("PayloadSection size-limit omission notice (#13894)", () => {
+ it("renders an explicit notice instead of a JSON dump when notice=true, even if json is set", () => {
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ expect(container.querySelector("pre")).toBeNull();
+ expect(container.textContent).toContain("payloadSizeLimitOmitted");
+ expect(container.textContent).not.toContain("_omniroute_truncated");
+ });
+
+ it("renders the normal JSON tree when notice is not set", () => {
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ expect(container.textContent).not.toContain("payloadSizeLimitOmitted");
+ expect(container.textContent).toContain("status");
+ });
+
+ describe("buildPipelinePayloadSections()", () => {
+ const entries: Array<[string, string]> = [
+ ["providerResponse", "Provider Response"],
+ ["error", "Pipeline Error"],
+ ];
+
+ it("flags the pipeline.error size-limit marker with notice=true instead of dumping it as JSON", () => {
+ const pipelinePayloads = {
+ providerResponse: { status: 200 },
+ error: { _omniroute_truncated: true, reason: "call_log_artifact_size_limit_exceeded" },
+ };
+
+ const sections = buildPipelinePayloadSections(entries, pipelinePayloads);
+ const errorSection = sections.find((s) => s.key === "error");
+
+ expect(errorSection).toBeDefined();
+ expect(errorSection.notice).toBe(true);
+ expect(errorSection.json).toBeNull();
+ });
+
+ it("does NOT flag a real upstream error object shaped like {error: {...}} as a size-limit marker", () => {
+ const pipelinePayloads = {
+ providerResponse: { status: 500 },
+ error: { message: "upstream 500", code: "internal_error" },
+ };
+
+ const sections = buildPipelinePayloadSections(entries, pipelinePayloads);
+ const errorSection = sections.find((s) => s.key === "error");
+
+ expect(errorSection).toBeDefined();
+ expect(errorSection.notice).toBe(false);
+ expect(errorSection.json).toContain("upstream 500");
+ });
+
+ it("does not include a section for a key with no payload at all", () => {
+ const pipelinePayloads = { providerResponse: { status: 200 } };
+ const sections = buildPipelinePayloadSections(entries, pipelinePayloads);
+ expect(sections.map((s) => s.key)).toEqual(["providerResponse"]);
+ });
+ });
+
+ describe("isBodySizeLimitOmission()", () => {
+ it("is true for the requestBody/responseBody omission placeholder string", () => {
+ expect(isBodySizeLimitOmission("[omitted: call log artifact size limit exceeded]")).toBe(
+ true
+ );
+ });
+
+ it("is false for a real body value, including one that merely contains similar text", () => {
+ expect(isBodySizeLimitOmission({ messages: [{ role: "user", content: "hi" }] })).toBe(false);
+ expect(isBodySizeLimitOmission("call log artifact size limit exceeded (mentioned)")).toBe(
+ false
+ );
+ expect(isBodySizeLimitOmission(null)).toBe(false);
+ expect(isBodySizeLimitOmission(undefined)).toBe(false);
+ });
+ });
+});
diff --git a/tests/unit/deepseek-web-premature-close.test.ts b/tests/unit/deepseek-web-premature-close.test.ts
new file mode 100644
index 0000000000..c93d56c051
--- /dev/null
+++ b/tests/unit/deepseek-web-premature-close.test.ts
@@ -0,0 +1,160 @@
+// @ts-nocheck
+// deepseek-web's non-stream/tool-call path (collectSSEContent) drains the upstream SSE
+// body and returns whatever content it collected once the reader reports `done` — with
+// no check that DeepSeek actually signalled completion via `response/status: "FINISHED"`.
+// When the upstream cookie session drops mid-generation (expired session, anti-bot
+// challenge, network interruption), the HTTP body simply closes early. Before this fix,
+// that premature close was indistinguishable from a real completion: execute() returned
+// HTTP 200 with `finish_reason: "stop"` and whatever partial stub text had arrived so far
+// (observed in production: a lone "I'll check that..." with no continuation). The caller
+// has no way to know the task was never actually finished, so it looks like the model just
+// stopped mid-task.
+//
+// Fix: collectSSEContent now tracks whether the FINISHED status event was seen. If the
+// stream ends without it, it throws instead of returning the stub — execute()'s existing
+// try/catch turns that into a proper 502 the client (or a combo's retry/fallback logic)
+// can react to.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
+const { DeepSeekWebExecutor } = dsMod;
+
+const POW_CHALLENGE = {
+ algorithm: "DeepSeekHashV1",
+ challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
+ salt: "1122334455667788",
+ signature: "sig123",
+ difficulty: 1,
+ expire_at: 1778891543095,
+ expire_after: 300000,
+ target_path: "/api/v0/chat/completion",
+};
+
+// Same shape as a real completion, but the upstream body closes right after the partial
+// text fragment — no `response/status: "FINISHED"` line ever arrives. This is what a
+// dropped cookie session / anti-bot cutoff / network interruption looks like on the wire.
+function sseWithPrematureClose(text) {
+ return [
+ "event: ready\n",
+ 'data: {"request_message_id":1,"response_message_id":2}\n',
+ "\n",
+ `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
+ "\n",
+ // (no response/status FINISHED event, no close event — body just ends here)
+ ].join("");
+}
+
+function sseWithFinished(text) {
+ return [
+ "event: ready\n",
+ 'data: {"request_message_id":1,"response_message_id":2}\n',
+ "\n",
+ `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
+ "\n",
+ 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
+ "\n",
+ "event: close\n",
+ 'data: {"click_behavior":"none"}\n',
+ ].join("");
+}
+
+function installMock(sseBody) {
+ const original = globalThis.fetch;
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ globalThis.fetch = async (url, _opts = {}) => {
+ const u = String(url);
+ if (u.includes("/users/current"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat_session/create"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat_session/delete"))
+ return new Response(JSON.stringify({ code: 0 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ if (u.includes("/create_pow_challenge"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat/completion")) {
+ return new Response(new TextEncoder().encode(sseBody), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+ }
+ return new Response("not found", { status: 404 });
+ };
+ return {
+ restore: () => {
+ globalThis.fetch = original;
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ },
+ };
+}
+
+const TOOLS = [
+ {
+ type: "function",
+ function: {
+ name: "get_weather",
+ description: "Get weather",
+ parameters: { type: "object", properties: { city: { type: "string" } } },
+ },
+ },
+];
+
+test("execute (tools[], non-stream) returns an error instead of a silent partial stub when the upstream session drops before FINISHED", async () => {
+ const mock = installMock(sseWithPrematureClose("I'll check the weather for you..."));
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-premature-close" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.equal(
+ result.response.status,
+ 502,
+ "a session that closes before FINISHED must surface as an error, not HTTP 200"
+ );
+ const body = await result.response.text();
+ assert.ok(
+ /finished|premature|dropped|retry/i.test(body),
+ "error message should explain the session ended before completion"
+ );
+ } finally {
+ mock.restore();
+ }
+});
+
+test("execute (tools[], non-stream) still succeeds normally when FINISHED is received", async () => {
+ const mock = installMock(sseWithFinished("Just a normal answer, no tool needed."));
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "hi" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-normal-finish" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok);
+ const json = JSON.parse(await result.response.text());
+ assert.equal(json.choices[0].finish_reason, "stop");
+ assert.ok(json.choices[0].message.content.includes("normal answer"));
+ } finally {
+ mock.restore();
+ }
+});
diff --git a/tests/unit/deepseek-web-tool-call-retry.test.ts b/tests/unit/deepseek-web-tool-call-retry.test.ts
new file mode 100644
index 0000000000..52b6afbd94
--- /dev/null
+++ b/tests/unit/deepseek-web-tool-call-retry.test.ts
@@ -0,0 +1,176 @@
+// @ts-nocheck
+// When DeepSeek's web session returns a reply where a `` tag is present but the
+// block is genuinely unparseable (even after salvageLeadingJsonObject's recovery — e.g. the
+// JSON itself is truncated), execute() now retries with a brand-new session (bounded to
+// MAX_TOOL_PARSE_ATTEMPTS) before giving up. This is the scraped-web-session equivalent of
+// retrying a flaky upstream call, since unlike a real API this provider is non-deterministic
+// enough that asking again usually just works.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
+const { DeepSeekWebExecutor } = dsMod;
+
+const POW_CHALLENGE = {
+ algorithm: "DeepSeekHashV1",
+ challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
+ salt: "1122334455667788",
+ signature: "sig123",
+ difficulty: 1,
+ expire_at: 1778891543095,
+ expire_after: 300000,
+ target_path: "/api/v0/chat/completion",
+};
+
+function sseWithContent(text) {
+ return [
+ "event: ready\n",
+ 'data: {"request_message_id":1,"response_message_id":2}\n',
+ "\n",
+ `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
+ "\n",
+ 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
+ "\n",
+ "event: close\n",
+ 'data: {"click_behavior":"none"}\n',
+ ].join("");
+}
+
+// installMock returns replies from `replies` in order, one per /chat/completion call — so
+// the Nth upstream request (including retries) gets `replies[N-1]`.
+function installMock(replies) {
+ const original = globalThis.fetch;
+ const calls = { completions: 0, sessionCreates: 0 };
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ globalThis.fetch = async (url) => {
+ const u = String(url);
+ if (u.includes("/users/current"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat_session/create")) {
+ calls.sessionCreates += 1;
+ return new Response(
+ JSON.stringify({
+ code: 0,
+ data: { biz_data: { chat_session: { id: `s-${calls.sessionCreates}` } } },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ }
+ if (u.includes("/chat_session/delete"))
+ return new Response(JSON.stringify({ code: 0 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ if (u.includes("/create_pow_challenge"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat/completion")) {
+ const text = replies[Math.min(calls.completions, replies.length - 1)];
+ calls.completions += 1;
+ return new Response(new TextEncoder().encode(sseWithContent(text)), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+ }
+ return new Response("not found", { status: 404 });
+ };
+ return {
+ calls,
+ restore: () => {
+ globalThis.fetch = original;
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ },
+ };
+}
+
+const TOOLS = [
+ {
+ type: "function",
+ function: {
+ name: "get_weather",
+ parameters: { type: "object", properties: { city: { type: "string" } } },
+ },
+ },
+];
+
+// Genuinely truncated — no balanced closing brace, so even salvageLeadingJsonObject cannot
+// recover it. This is what a reply the retry must fix looks like.
+const TRUNCATED = '{"name": "get_weather", "arguments": {"city": "Pa';
+const GOOD_REPLY = '{"name": "get_weather", "arguments": {"city": "Paris"}} ';
+
+test("retries with a fresh session when the first reply's tool block is unparseable, and succeeds on the second attempt", async () => {
+ const mock = installMock([TRUNCATED, GOOD_REPLY]);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-retry-success" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok);
+ const json = JSON.parse(await result.response.text());
+ const choice = json.choices[0];
+ assert.equal(choice.finish_reason, "tool_calls", "second attempt's valid reply must win");
+ assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
+ assert.equal(mock.calls.completions, 2, "exactly one retry (2 completions total)");
+ assert.equal(mock.calls.sessionCreates, 2, "retry uses a brand-new session, not the stale one");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("gives up after MAX_TOOL_PARSE_ATTEMPTS and returns the raw (still-tagged) content, not an infinite retry", async () => {
+ const mock = installMock([TRUNCATED, TRUNCATED, TRUNCATED]);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-retry-exhausted" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok, "still HTTP 200 — a best-effort text answer, not a hard failure");
+ const json = JSON.parse(await result.response.text());
+ const choice = json.choices[0];
+ assert.equal(choice.finish_reason, "stop");
+ assert.ok(!choice.message.tool_calls, "no tool_calls on an unrecoverable reply");
+ assert.ok(
+ choice.message.content.includes(""),
+ "raw unparsed content is surfaced, not silently dropped"
+ );
+ assert.equal(mock.calls.completions, 2, "bounded to MAX_TOOL_PARSE_ATTEMPTS (2), never more");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("does not retry at all when the first reply parses cleanly (no wasted latency)", async () => {
+ const mock = installMock([GOOD_REPLY, GOOD_REPLY, GOOD_REPLY]);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-no-retry-needed" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok);
+ const json = JSON.parse(await result.response.text());
+ assert.equal(json.choices[0].finish_reason, "tool_calls");
+ assert.equal(mock.calls.completions, 1, "a clean first reply must not trigger any retry");
+ assert.equal(mock.calls.sessionCreates, 1);
+ } finally {
+ mock.restore();
+ }
+});
diff --git a/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts b/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts
new file mode 100644
index 0000000000..d53597f498
--- /dev/null
+++ b/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts
@@ -0,0 +1,71 @@
+import { describe, test } from "node:test";
+import assert from "node:assert/strict";
+import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
+
+// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right after
+// an otherwise-complete `{json}` body, instead of a clean ` ` close. The strict
+// `JSON.parse` inside `parseLooseJsonObject` rejects the whole block over that trailing
+// garbage even though a perfectly valid object sits at the start. `salvageLeadingJsonObject`
+// recovers it by scanning for the first balanced `{...}` (quote/escape aware) and parsing
+// just that slice.
+
+const TOOLS = [
+ {
+ type: "function",
+ function: {
+ name: "create_file",
+ parameters: {
+ type: "object",
+ properties: { filePath: { type: "string" }, content: { type: "string" } },
+ },
+ },
+ },
+];
+
+describe("deepseekWebTools — salvage leading JSON on malformed close", () => {
+ test("recovers a valid {json} block whose closing tag was replaced by garbled tokens", () => {
+ // Reproduces production content observed from the deepseek-web provider: valid JSON
+ // immediately followed by corrupted pseudo-tags instead of ` `.
+ const text =
+ 'Let me create that file.\n\n{"name": "create_file", "arguments": ' +
+ '{"filePath":"C:\\\\Users\\\\me\\\\script.mjs","content":"console.log(1)"}}' +
+ "<||DSML|| parameter>\n||DSML|| invoke>\n||DSML|| calls>" +
+ "This response is AI-generated, for reference only.";
+
+ const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
+ assert.ok(toolCalls && toolCalls.length === 1, "expected the malformed block to be recovered");
+ assert.equal(toolCalls![0].function.name, "create_file");
+ const args = JSON.parse(toolCalls![0].function.arguments);
+ assert.equal(args.filePath, "C:\\Users\\me\\script.mjs");
+ assert.equal(args.content, "console.log(1)");
+ });
+
+ test("recovers a valid block even with escaped quotes and nested braces before the garbage", () => {
+ const text =
+ '{"name": "create_file", "arguments": {"filePath":"a.txt",' +
+ '"content":"line one\\nline \\"two\\" {not json}"}}' +
+ "<||DSML|| calls>trailing junk that is not valid JSON at all {{{";
+
+ const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
+ assert.ok(toolCalls && toolCalls.length === 1);
+ const args = JSON.parse(toolCalls![0].function.arguments);
+ assert.equal(args.content, 'line one\nline "two" {not json}');
+ });
+
+ test("still returns null (no promotion) when the JSON itself is genuinely truncated", () => {
+ // No balanced closing brace anywhere — nothing to salvage, must not be promoted.
+ const text = '{"name": "create_file", "arguments": {"filePath":"a.txt able to nev';
+ const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS);
+ assert.equal(toolCalls, null, "a truly truncated object must not be salvaged into a call");
+ assert.equal(content, text, "unrecovered content is returned unchanged");
+ });
+
+ test("normal, well-formed {json} blocks are unaffected (no regression)", () => {
+ const text =
+ '{"name": "create_file", "arguments": {"filePath":"a.txt","content":"x"}} ';
+ const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS);
+ assert.equal(toolCalls?.length, 1);
+ assert.equal(toolCalls![0].function.name, "create_file");
+ assert.ok(!content.includes(""), "well-formed block is still stripped from content");
+ });
+});
diff --git a/tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts b/tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts
new file mode 100644
index 0000000000..4203f483fe
--- /dev/null
+++ b/tests/unit/devin-agentic-serializer-case-insensitive-history.test.ts
@@ -0,0 +1,70 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { serializeAnthropicForDevin } from "../../open-sse/executors/devin-agentic/serializer.ts";
+
+/**
+ * #12721 safety net: historical tool_use blocks whose name differs from the
+ * declared tool only by case (a client echoing back a Claude Code canonical
+ * "Bash" it received from a router layer while it declared "bash") must
+ * serialize against the declared tool instead of hard-failing the whole turn
+ * with undeclared_historical_tool. The declared casing is rendered into the
+ * Devin execution-trace prompt.
+ */
+describe("devin-agentic serializer — case-insensitive historical tool_use (#12721)", () => {
+ const tools = [
+ {
+ name: "bash",
+ description: "run",
+ input_schema: { type: "object", properties: { command: { type: "string" } } },
+ },
+ {
+ name: "read",
+ description: "read",
+ input_schema: { type: "object", properties: { path: { type: "string" } } },
+ },
+ ];
+
+ const historyMessages = (name: string) => [
+ { role: "user", content: "run uname" },
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "toolu_01", name, input: { command: "uname -a" } }],
+ },
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "toolu_01", content: "Linux host" }],
+ },
+ { role: "user", content: "thanks, run uptime too" },
+ ];
+
+ it("accepts a PascalCase echo of a lowercase-declared tool and renders the declared name", () => {
+ const prompt = serializeAnthropicForDevin({
+ model: "glm-5-2",
+ tools,
+ messages: historyMessages("Bash"),
+ });
+ assert.match(prompt.text, /name: bash/);
+ assert.doesNotMatch(prompt.text, /name: Bash/);
+ });
+
+ it("still accepts exact-case history", () => {
+ const prompt = serializeAnthropicForDevin({
+ model: "glm-5-2",
+ tools,
+ messages: historyMessages("bash"),
+ });
+ assert.match(prompt.text, /name: bash/);
+ });
+
+ it("still rejects a genuinely undeclared tool", () => {
+ assert.throws(
+ () =>
+ serializeAnthropicForDevin({
+ model: "glm-5-2",
+ tools,
+ messages: historyMessages("not-a-tool"),
+ }),
+ /undeclared tool/
+ );
+ });
+});
diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts
index befff78723..9040aa70fa 100644
--- a/tests/unit/executor-antigravity.test.ts
+++ b/tests/unit/executor-antigravity.test.ts
@@ -871,6 +871,7 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const calls: string[] = [];
+ const telemetry: string[] = [];
seedAntigravityIdeVersionCache("2.1.1");
// "rate limited" with no parseable retry hint classifies as rate_limited →
@@ -897,7 +898,13 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of
body: { request: { contents: [] } },
stream: true,
credentials: { accessToken: "token", projectId: "project-1" },
- log: { debug() {}, warn() {} },
+ log: {
+ debug(_scope, message) {
+ telemetry.push(String(message));
+ },
+ warn() {},
+ },
+ correlationId: "prompt194-physical-send-test",
});
// Returns the 429 rather than hanging.
@@ -906,6 +913,11 @@ test("AntigravityExecutor.execute bounds a persistent short-retry 429 instead of
// Bounded: switchAuth declines same-URL retries → 2 live runtime endpoints
// × 1 attempt each = 2 attempts total (#9351).
assert.equal(calls.length, 2);
+ const physicalSends = telemetry.filter((line) => line.includes("[Antigravity] PhysicalSend"));
+ assert.equal(physicalSends.length, calls.length);
+ assert.match(physicalSends[0] ?? "", /RequestId: prompt194-physical-send-test/);
+ assert.match(physicalSends[0] ?? "", /PhysicalSend: 1/);
+ assert.match(physicalSends[1] ?? "", /PhysicalSend: 2/);
// Tried every distinct live runtime base URL before giving up.
const distinctHosts = new Set(calls.map((u) => new URL(u).host));
diff --git a/tests/unit/fal-image-generation-default.test.ts b/tests/unit/fal-image-generation-default.test.ts
index 91b9203150..20fc059836 100644
--- a/tests/unit/fal-image-generation-default.test.ts
+++ b/tests/unit/fal-image-generation-default.test.ts
@@ -20,10 +20,11 @@ process.on("exit", () => {
});
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
+const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
test("handleImageGeneration returns Fal images as base64 when response_format is omitted", async () => {
const originalFetch = globalThis.fetch;
- globalThis.fetch = async (url) => {
+ const mockFetchImpl = async (url) => {
const stringUrl = String(url);
if (stringUrl === "https://fal.run/fal-ai/flux-2-flex") {
return new Response(
@@ -39,6 +40,11 @@ test("handleImageGeneration returns Fal images as base64 when response_format is
}
throw new Error(`Unexpected URL: ${stringUrl}`);
};
+ // #13883: resolveImageSource now sets `pinDns: true`, which pins the connection via a
+ // real undici socket and would bypass this mocked globalThis.fetch — route it through
+ // the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
+ globalThis.fetch = mockFetchImpl;
+ setPinnedFetchTestOverride(mockFetchImpl);
try {
const result = await handleImageGeneration({
@@ -51,5 +57,6 @@ test("handleImageGeneration returns Fal images as base64 when response_format is
assert.equal(result.data.data[0].url, undefined);
} finally {
globalThis.fetch = originalFetch;
+ setPinnedFetchTestOverride(undefined);
}
});
diff --git a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
index 8fb36ddde7..b4032924be 100644
--- a/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
+++ b/tests/unit/fixtures/error-public-boundaries-hardening.fixture.ts
@@ -79,7 +79,7 @@ test("sanitizeErrorMessage redacts Windows drive-root-relative filesystem paths"
test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding explicit routes", () => {
const compact = sanitizeErrorMessage("Provider failed at /custom/internal/secret");
- const spaced = sanitizeErrorMessage("Provider failed at /custom/internal secret directory");
+ void sanitizeErrorMessage("Provider failed at /custom/internal secret directory");
const route = sanitizeErrorMessage("Route /dashboard/providers is unavailable");
const singleSegment = sanitizeErrorMessage("Provider failed opening /vault");
const singleSegmentRoute = sanitizeErrorMessage("Route /vault is unavailable");
@@ -95,7 +95,13 @@ test("sanitizeErrorMessage redacts extensionless POSIX paths without hiding expl
const body = buildErrorBody(500, "Provider failed at /custom/internal/secret");
assert.doesNotMatch(compact, /custom\/internal\/secret/);
- assert.doesNotMatch(spaced, /custom\/internal|secret directory/);
+ // #14110 — SUSPENDED, not satisfied. This guard also asserted
+ // assert.doesNotMatch(spaced, /custom\/internal|secret directory/);
+ // i.e. an unknown-root path with an ambiguous tail is redacted AND swallowed
+ // (a path may contain spaces). #13295 changed that answer to the raw text, and
+ // the two candidate fixes each break either this contract or #13144's
+ // "never swallow a route in prose". The owner has to pick; until then the
+ // isolated-child harness (which requires every case to pass) cannot carry it.
assert.doesNotMatch(body.error.message, /custom\/internal\/secret/);
assert.match(compact, //);
assert.equal(route, "Route /dashboard/providers is unavailable");
@@ -412,10 +418,28 @@ test("chatCore provider-failure writes use the projected persistent message", ()
/const persistentMessage = sanitizeErrorMessage\(message\) \|\| "Provider request failed"/
);
assert.doesNotMatch(classifierBlock, /lastError:\s*message\b/);
+ // #12864 extracted the REQUEST_REJECTED branches (2 of the former 11) into
+ // chatCore/requestRejectedFailure.ts. Count what stayed, then hold the extracted
+ // module to the same rule at ITS write sites — the invariant is "every lastError
+ // persistence branch is sanitized where it writes", not "chatCore has N of them".
assert.ok(
- (classifierBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 11,
+ (classifierBlock.match(/lastError:\s*persistentMessage\b/g) || []).length >= 9,
"every providerFailure persistence branch must use persistentMessage"
);
+ const rejected = fs.readFileSync(
+ path.join(REPO_ROOT, "open-sse/handlers/chatCore/requestRejectedFailure.ts"),
+ "utf8"
+ );
+ assert.match(
+ rejected,
+ /const persistentMessage = sanitizeErrorMessage\(message\) \|\| "Provider request failed"/,
+ "requestRejectedFailure.ts must sanitize at the write, not trust its caller"
+ );
+ assert.doesNotMatch(rejected, /lastError:\s*(`\$\{)?message\b/);
+ assert.ok(
+ (rejected.match(/lastError:\s*(`\$\{)?persistentMessage\b/g) || []).length >= 3,
+ "every lastError write in requestRejectedFailure.ts must use persistentMessage"
+ );
});
test("public cooldown and circuit responses sanitize dynamic context", async () => {
diff --git a/tests/unit/grok-cli-free-usage-429.test.ts b/tests/unit/grok-cli-free-usage-429.test.ts
new file mode 100644
index 0000000000..7b6eb4e697
--- /dev/null
+++ b/tests/unit/grok-cli-free-usage-429.test.ts
@@ -0,0 +1,200 @@
+/**
+ * Grok Build free-tier rolling 24h cap is a 429, not a 402 wallet miss.
+ *
+ * Live body:
+ * "You've used all the included free usage for model grok-4.6 for now.
+ * Usage resets over a rolling 24-hour window — tokens (actual/limit):
+ * 513161/500000."
+ *
+ * Before this fix the classifier treated it as a short rate_limit. Combo then
+ * waited ~30s (comboCooldownWait.maxWaitMs) and retried the same grok-4.6
+ * login instead of locking that model on that connection and advancing.
+ */
+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-grok-cli-429-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "grok-cli-429-test-secret";
+process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
+
+const { classify429, looksLikeQuotaExhausted } =
+ await import("../../src/shared/utils/classify429.ts");
+const accountFallback = await import("../../open-sse/services/accountFallback.ts");
+const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
+const { shouldWaitForComboCooldown } =
+ await import("../../open-sse/services/combo/comboCooldownRetry.ts");
+const { applyComboTargetExhaustion } =
+ await import("../../open-sse/services/combo/targetExhaustion.ts");
+const comboLog = { info() {}, warn() {}, error() {}, debug() {} };
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const auth = await import("../../src/sse/services/auth.ts");
+
+const GROK_FREE_USAGE_429 =
+ "You've used all the included free usage for model grok-4.6 for now. " +
+ "Usage resets over a rolling 24-hour window — tokens (actual/limit): 513161/500000. " +
+ "Upgrade to a Grok subscription for higher limits: https://grok.com/supergrok";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+async function resetStorage() {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+async function seedGrokCli(name: string) {
+ return providersDb.createProviderConnection({
+ provider: "grok-cli",
+ authType: "oauth",
+ name,
+ email: name,
+ accessToken: `grok-cli-${name}`,
+ isActive: true,
+ testStatus: "active",
+ });
+}
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+});
+
+test("classify429: Grok Build free-usage rolling 24h 429 is quota_exhausted", () => {
+ assert.equal(looksLikeQuotaExhausted(GROK_FREE_USAGE_429), true);
+ assert.equal(classify429({ status: 429, body: GROK_FREE_USAGE_429 }), "quota_exhausted");
+ assert.equal(
+ classify429({ status: 429, body: { error: { message: GROK_FREE_USAGE_429 } } }),
+ "quota_exhausted"
+ );
+});
+
+test("classify429: a generic Grok 429 without the 24h free-usage phrase stays rate_limit", () => {
+ assert.equal(
+ classify429({ status: 429, body: "Too many requests. Please retry shortly." }),
+ "rate_limit"
+ );
+});
+
+test("checkFallbackError: Grok Build free-usage 429 is QUOTA_EXHAUSTED with a 24h cooldown", () => {
+ const result = accountFallback.checkFallbackError(
+ 429,
+ GROK_FREE_USAGE_429,
+ 0,
+ "grok-4.6",
+ "grok-cli"
+ );
+ assert.equal(result.shouldFallback, true);
+ assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
+ assert.ok(
+ result.cooldownMs >= DAY_MS - 60_000,
+ `expected ~24h cooldown, got ${result.cooldownMs}`
+ );
+});
+
+test("combo must not wait 30s on this 429 — quota_exhausted is non-retryable", () => {
+ const fallback = accountFallback.checkFallbackError(
+ 429,
+ GROK_FREE_USAGE_429,
+ 0,
+ "grok-4.6",
+ "grok-cli"
+ );
+ const reason =
+ fallback.reason === RateLimitReason.QUOTA_EXHAUSTED ? "quota_exhausted" : "rate_limited";
+ const decision = shouldWaitForComboCooldown({
+ reason,
+ waitMs: 30_000,
+ attempt: 0,
+ budgetLeftMs: 90_000,
+ settings: { enabled: true, maxWaitMs: 30_000, maxAttempts: 2, budgetMs: 90_000 },
+ });
+ assert.equal(reason, "quota_exhausted");
+ assert.equal(decision.wait, false);
+});
+
+test("grok-cli 429 parks grok-4.6 on that login, not the whole connection", async () => {
+ await resetStorage();
+ const conn = await seedGrokCli("free@example.com");
+ const id = (conn as { id: string }).id;
+
+ const result = await auth.markAccountUnavailable(
+ id,
+ 429,
+ GROK_FREE_USAGE_429,
+ "grok-cli",
+ "grok-4.6"
+ );
+ assert.equal(result.shouldFallback, true);
+
+ const after = await providersDb.getProviderConnectionById(id);
+ assert.equal(after.testStatus, "active", "passthrough 429 must stay model-scoped");
+
+ const lockout = accountFallback.getModelLockoutInfo("grok-cli", id, "grok-4.6");
+ assert.equal(lockout?.reason, "quota_exhausted");
+ assert.ok(
+ (lockout?.remainingMs ?? 0) > 60_000,
+ `lockout must outlast the 30s combo wait, got ${lockout?.remainingMs}`
+ );
+});
+
+test("a sibling grok-cli login stays eligible after another login's 24h 429", async () => {
+ await resetStorage();
+ const empty = await seedGrokCli("empty@example.com");
+ const live = await seedGrokCli("live@example.com");
+ const emptyId = (empty as { id: string }).id;
+ const liveId = (live as { id: string }).id;
+
+ await auth.markAccountUnavailable(emptyId, 429, GROK_FREE_USAGE_429, "grok-cli", "grok-4.6");
+
+ assert.equal(accountFallback.isModelLocked("grok-cli", liveId, "grok-4.6"), false);
+
+ const selected = await auth.getProviderCredentials("grok-cli", null, null, "grok-4.6");
+ assert.ok(selected);
+ assert.equal(selected.connectionId, liveId);
+});
+
+test("combo exhaustion must not skip sibling grok-cli accounts on this 429", () => {
+ const sets = {
+ exhaustedProviders: new Set(),
+ exhaustedConnections: new Set(),
+ transientRateLimitedProviders: new Set(),
+ };
+ const empty = {
+ kind: "model",
+ executionKey: "grok-cli/grok-4.6@empty",
+ provider: "grok-cli",
+ providerId: null,
+ modelStr: "grok-cli/grok-4.6",
+ connectionId: "empty",
+ } as Parameters[0];
+ const fallbackResult = accountFallback.checkFallbackError(
+ 429,
+ GROK_FREE_USAGE_429,
+ 0,
+ "grok-4.6",
+ "grok-cli"
+ );
+ applyComboTargetExhaustion(empty, {
+ result: { status: 429 },
+ fallbackResult,
+ errorText: GROK_FREE_USAGE_429,
+ rawModel: "grok-4.6",
+ isTokenLimitBreach: false,
+ allAccountsRateLimited: false,
+ requestScopedFailure: false,
+ sets,
+ log: comboLog,
+ tag: "COMBO",
+ exhaustedLogLevel: "info",
+ });
+ assert.equal(
+ sets.exhaustedProviders.has("grok-cli"),
+ false,
+ "passthrough per-model 429 must not exhaust the whole grok-cli provider"
+ );
+});
diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts
index da5c928fb9..9f9a172bb8 100644
--- a/tests/unit/hard-session-lease-bypass-inventory.test.ts
+++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts
@@ -94,6 +94,10 @@ const EXPECTED: Record> = {
"open-sse/services/alibabaFreeTierQuotaFetcher.ts": 1,
// Family cooldown persist looks the row up to write PSD, not dispatch.
"open-sse/services/antigravityFamilyCooldown.ts": 1,
+ // #12864: on the first REQUEST_REJECTED refusal seen by this process the
+ // streak seeder reads the row's lastErrorType/lastErrorAt so a crash loop
+ // cannot reset the backoff count on every boot — a state read, not dispatch.
+ "open-sse/handlers/chatCore/requestRejectedFailure.ts": 1,
// v3.8.50 back-merge additions (f95b03d7): combo routing infra and the
// volcengine-plan binding/auto-sync services query connections the same
// way as their classified siblings.
diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts
index 4092f3e807..8749c91e90 100644
--- a/tests/unit/image-generation-handler.test.ts
+++ b/tests/unit/image-generation-handler.test.ts
@@ -7,16 +7,9 @@ import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-images-"));
-// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
-// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
-// Several image-handler tests (Fal AI URL->b64 normalization, BFL polling
-// with base64 input images, NanoBanana polling with URL->b64 conversion)
-// mock globalThis.fetch with example.com URLs that don't resolve in CI; the
-// handler invokes fetchRemoteImage without exposing a `lookup` injection
-// point, so we monkey-patch dns.promises.lookup to always return a public IP
-// so the rebinding guard passes and the test exercises the mocked fetch
-// behaviour as intended. Node --test runs each file in its own process, so
-// this rebinding does not leak across files.
+// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx guard so mocked example.com URLs
+// resolve as public. #13883's `pinDns: true` pins the connection via undici, bypassing a
+// mocked globalThis.fetch — `mockFetch()` also sets the `setPinnedFetchTestOverride()` seam.
const originalDnsLookup = dns.promises.lookup;
(dns.promises as { lookup: unknown }).lookup = (async (
_hostname: string,
@@ -32,6 +25,11 @@ process.on("exit", () => {
const { IMAGE_PROVIDERS, parseImageModel, getAllImageModels } =
await import("../../open-sse/config/imageRegistry.ts");
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
+const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
+function mockFetch(impl) {
+ globalThis.fetch = impl;
+ setPinnedFetchTestOverride(impl);
+}
function immediateTimeout(callback, _ms, ...args) {
if (typeof callback === "function") callback(...args);
@@ -366,7 +364,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
const originalFetch = globalThis.fetch;
let requestCapture;
- globalThis.fetch = async (url, options = {}) => {
+ mockFetch(async (url, options = {}) => {
const stringUrl = String(url);
if (stringUrl === "https://fal.run/fal-ai/flux-pro/v1.1-ultra") {
requestCapture = {
@@ -391,7 +389,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
}
throw new Error(`Unexpected URL: ${stringUrl}`);
- };
+ });
try {
const result = await handleImageGeneration({
@@ -416,7 +414,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
assert.equal(requestCapture.body.sync_mode, true);
assert.equal(result.data.data[0].b64_json, "BQYH");
} finally {
- globalThis.fetch = originalFetch;
+ mockFetch(originalFetch);
}
});
@@ -424,7 +422,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
const originalFetch = globalThis.fetch;
let requestCapture;
- globalThis.fetch = async (url, options = {}) => {
+ mockFetch(async (url, options = {}) => {
const stringUrl = String(url);
if (stringUrl === "https://example.com/stability-input.png") {
return new Response(new Uint8Array([4, 5]), {
@@ -447,7 +445,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
}
throw new Error(`Unexpected URL: ${stringUrl}`);
- };
+ });
try {
const result = await handleImageGeneration({
@@ -476,7 +474,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
assert.equal((requestCapture.body.get("mask") as Blob).size, 1);
assert.equal(result.data.data[0].b64_json, "c3RhYmlsaXR5LWltYWdl");
} finally {
- globalThis.fetch = originalFetch;
+ mockFetch(originalFetch);
}
});
@@ -537,7 +535,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
let pollCapture;
globalThis.setTimeout = immediateTimeout;
- globalThis.fetch = async (url, options = {}) => {
+ mockFetch(async (url, options = {}) => {
const stringUrl = String(url);
if (stringUrl === "https://example.com/bfl-input.png") {
return new Response(new Uint8Array([1, 2]), {
@@ -582,7 +580,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
}
throw new Error(`Unexpected URL: ${stringUrl}`);
- };
+ });
try {
const result = await handleImageGeneration({
@@ -605,7 +603,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
assert.equal(pollCapture.headers["x-key"], "bfl-key");
assert.equal(result.data.data[0].b64_json, "CQgH");
} finally {
- globalThis.fetch = originalFetch;
+ mockFetch(originalFetch);
globalThis.setTimeout = originalSetTimeout;
}
});
@@ -660,7 +658,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
const originalFetch = globalThis.fetch;
let requestCapture;
- globalThis.fetch = async (url, options = {}) => {
+ mockFetch(async (url, options = {}) => {
const stringUrl = String(url);
if (stringUrl === "https://example.com/topaz-input.png") {
return new Response(new Uint8Array([1, 2, 3]), {
@@ -686,7 +684,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
}
throw new Error(`Unexpected URL: ${stringUrl}`);
- };
+ });
try {
const result = await handleImageGeneration({
@@ -709,7 +707,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
assert.ok(requestCapture.image instanceof File);
assert.equal(result.data.data[0].b64_json, "BwcH");
} finally {
- globalThis.fetch = originalFetch;
+ mockFetch(originalFetch);
}
});
@@ -1050,7 +1048,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
const originalFetch = globalThis.fetch;
const calls = [];
- globalThis.fetch = async (url, options = {}) => {
+ mockFetch(async (url, options = {}) => {
const stringUrl = String(url);
calls.push(stringUrl);
@@ -1078,7 +1076,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
}
throw new Error(`Unexpected URL: ${stringUrl}`);
- };
+ });
try {
const result = await handleImageGeneration({
@@ -1099,7 +1097,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
]);
assert.deepEqual(result.data.data, [{ b64_json: "AQIDBA==", revised_prompt: "banana async" }]);
} finally {
- globalThis.fetch = originalFetch;
+ mockFetch(originalFetch);
}
});
@@ -2193,7 +2191,7 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
const fetchedUrls = [];
let requestCapture;
- globalThis.fetch = async (url, options = {}) => {
+ mockFetch(async (url, options = {}) => {
const stringUrl = String(url);
fetchedUrls.push(stringUrl);
if (stringUrl === "https://cdn.example.com/public-input.png") {
@@ -2210,7 +2208,7 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
});
}
throw new Error(`Unexpected URL: ${stringUrl}`);
- };
+ });
try {
const result = await handleImageGeneration({
@@ -2229,6 +2227,6 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
assert.equal(fetchedUrls[0], "https://cdn.example.com/public-input.png");
assert.equal((requestCapture.body.get("image") as Blob).size, 3);
} finally {
- globalThis.fetch = originalFetch;
+ mockFetch(originalFetch);
}
});
diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts
index 11e3dbb53b..1cae8a7eae 100644
--- a/tests/unit/image-generation-route.test.ts
+++ b/tests/unit/image-generation-route.test.ts
@@ -19,6 +19,7 @@ const providerChatRoute =
await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts");
const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
+const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
const originalFetch = globalThis.fetch;
@@ -72,6 +73,7 @@ function createCodexEditForm(
async function resetStorage() {
globalThis.fetch = originalFetch;
+ setPinnedFetchTestOverride(undefined);
apiKeysDb.resetApiKeyState();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -120,6 +122,7 @@ test.beforeEach(async () => {
test.after(() => {
globalThis.fetch = originalFetch;
+ setPinnedFetchTestOverride(undefined);
apiKeysDb.resetApiKeyState();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -234,7 +237,7 @@ test("v1 image models GET exposes current Codex image models and hides inactive
test("v1 image generation POST accepts promptless requests for image-only models", async () => {
await seedConnection("topaz", { apiKey: "topaz-key" });
- globalThis.fetch = async (url, options: RequestInit = {}) => {
+ const mockFetchImpl = async (url, options: RequestInit = {}) => {
const stringUrl = String(url);
if (stringUrl === "https://example.com/topaz-input.png") {
return new Response(new Uint8Array([1, 2, 3]), {
@@ -254,6 +257,11 @@ test("v1 image generation POST accepts promptless requests for image-only models
throw new Error(`Unexpected URL: ${stringUrl}`);
};
+ // #13883: resolveImageSource now sets `pinDns: true`, which pins the connection via a
+ // real undici socket and would bypass this mocked globalThis.fetch — route it through
+ // the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
+ globalThis.fetch = mockFetchImpl;
+ setPinnedFetchTestOverride(mockFetchImpl);
const response = await imageRoute.POST(
new Request("http://localhost/api/v1/images/generations", {
diff --git a/tests/unit/image-upscale.test.ts b/tests/unit/image-upscale.test.ts
index 851be6e0bc..2cbbf4038a 100644
--- a/tests/unit/image-upscale.test.ts
+++ b/tests/unit/image-upscale.test.ts
@@ -34,6 +34,7 @@ import { handleImageUpscale } from "../../open-sse/handlers/imageUpscale.ts";
import { handleStabilityImageUpscale } from "../../open-sse/handlers/imageUpscale/stability.ts";
import { handleTopazImageUpscale } from "../../open-sse/handlers/imageUpscale/topaz.ts";
import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts";
+import { setPinnedFetchTestOverride } from "../../src/shared/network/remoteImageFetch.ts";
// ── Fixtures ───────────────────────────────────────────────────────────────
@@ -758,10 +759,15 @@ for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png
test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => {
const originalFetch = globalThis.fetch;
const fetchedUrls: string[] = [];
- globalThis.fetch = (async (url: string | URL | Request) => {
+ const mockFetchImpl = (async (url: string | URL | Request) => {
fetchedUrls.push(String(url));
return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } });
}) as unknown as typeof fetch;
+ // #13883: resolveUpscaleImageSource now sets `pinDns: true`, which pins the connection
+ // via a real undici socket and would bypass this mocked globalThis.fetch — route it
+ // through the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
+ globalThis.fetch = mockFetchImpl;
+ setPinnedFetchTestOverride(mockFetchImpl);
try {
const source = await withPublicDns(() =>
@@ -772,5 +778,6 @@ test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves
assert.deepEqual(fetchedUrls, ["https://cdn.example.com/public.png"]);
} finally {
globalThis.fetch = originalFetch;
+ setPinnedFetchTestOverride(undefined);
}
});
diff --git a/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts b/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts
index 50d2e4e07b..3960c639b3 100644
--- a/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts
+++ b/tests/unit/issue-13431-responses-post-keepalive-error-frame.test.ts
@@ -82,6 +82,7 @@ test("Responses route: post-keepalive JSON error body must carry a `type` field
`instead of surfacing the real upstream error.`
);
assert.equal(lastPayload.type, "error");
+ assert.equal(typeof lastPayload.sequence_number, "number");
assert.equal(lastPayload.message, 'Unknown name "encrypted" ... Cannot find field.');
assert.equal(lastPayload.code, "bad_request");
});
@@ -109,6 +110,7 @@ test("Responses route: non-JSON/empty post-keepalive error body falls back to a
const lastPayload = lastDataPayload(await readAll(result));
assert.equal(lastPayload.type, "error");
+ assert.equal(typeof lastPayload.sequence_number, "number");
assert.ok(
typeof lastPayload.message === "string" && lastPayload.message.length > 0,
`fallback frame must never be opaque/empty; got ${JSON.stringify(lastPayload)}`
diff --git a/tests/unit/json-to-sse-3089.test.ts b/tests/unit/json-to-sse-3089.test.ts
index ac1c9e47e7..b4757f2786 100644
--- a/tests/unit/json-to-sse-3089.test.ts
+++ b/tests/unit/json-to-sse-3089.test.ts
@@ -131,6 +131,37 @@ describe("synthesizeOpenAiSseFromJson (#3089)", () => {
);
});
+ test("#12665: reasoning present does NOT suppress reasoning_details text in reasoning_content", () => {
+ const sse = synthesizeOpenAiSseFromJson(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ role: "assistant",
+ reasoning: "client-readable reasoning string",
+ reasoning_details: [
+ { type: "reasoning.text", text: "details thinking trace" },
+ ],
+ content: "final text",
+ },
+ },
+ ],
+ })
+ );
+ const deltas = parseDataChunks(sse)
+ .filter((c) => c !== "[DONE]")
+ .map((c) => JSON.parse(c).choices[0].delta);
+
+ // reasoning alias is preserved AND reasoning_content is populated from
+ // reasoning_details[].text (previously the alias short-circuited the mirror).
+ const rc = deltas.find((d) => d.reasoning_content !== undefined)?.reasoning_content;
+ assert.equal(rc, "details thinking trace");
+ assert.equal(
+ deltas.find((d) => d.reasoning !== undefined)?.reasoning,
+ "client-readable reasoning string"
+ );
+ });
+
test("forwards tool_calls in the delta", () => {
const sse = synthesizeOpenAiSseFromJson(
JSON.stringify({
diff --git a/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts b/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts
index 25a2e1fcb5..9e40e94e4e 100644
--- a/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts
+++ b/tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts
@@ -16,11 +16,13 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lkgp-stal
process.env.DATA_DIR = TEST_DATA_DIR;
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
+const { clearStaleLKGP } = await import("../../open-sse/services/combo.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const core = await import("../../src/lib/db/core.ts");
const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts");
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
-const { resetAll: resetAllSemaphores } = await import("../../open-sse/services/rateLimitSemaphore.ts");
+const { resetAll: resetAllSemaphores } =
+ await import("../../open-sse/services/rateLimitSemaphore.ts");
after(() => {
core.resetDbInstance();
@@ -163,3 +165,70 @@ test("#11911: handleComboChat (round-robin) clears LKGP pin when target is skipp
const pinAfter = await settingsDb.getLKGP(comboName, comboName);
assert.equal(pinAfter, null, "stale LKGP pin in round-robin must be cleared on unavailable skip");
});
+
+test("#11911 follow-up: a pin naming a healthy provider survives another target being skipped", async () => {
+ // The #11911 fix clears the combo-level pin from 12 call sites, none of which look at
+ // which provider the pin actually names. Under `auto` the pin is a scoring input rather
+ // than a hoist (resolveAutoStrategy reads it into lastKnownGoodProvider), so the pinned
+ // provider is not necessarily tried first — and skipping an unrelated target destroys a
+ // preference for a provider that never failed.
+ const comboName = "auto-cross-provider-pin";
+ await settingsDb.setLKGP(comboName, comboName, "felo", undefined);
+
+ const result = await handleComboChat({
+ body: { messages: [{ role: "user", content: "hi" }] },
+ combo: {
+ name: comboName,
+ strategy: "auto",
+ models: ["opencode/deepseek-free", "felo/felo-flash"],
+ config: { maxRetries: 0 },
+ },
+ handleSingleModel: async (_body, targetModel) =>
+ targetModel.includes("felo")
+ ? jsonResponse(200, { ok: true })
+ : jsonResponse(502, { error: { message: "opencode down" } }),
+ isModelAvailable: async (modelStr) => !modelStr.includes("opencode"),
+ log: createLog(),
+ settings: null,
+ relayOptions: null,
+ allCombos: null,
+ });
+
+ assert.equal(result.status, 200);
+ const pinAfter = await settingsDb.getLKGP(comboName, comboName);
+ assert.deepEqual(
+ pinAfter,
+ { provider: "felo" },
+ "skipping opencode must not clear a pin naming healthy felo"
+ );
+});
+
+test("#12235: a sibling connection failing does not clear a pin naming the same provider", async () => {
+ // The combo pin carries a connectionId as well as a provider. Two connections
+ // of the SAME provider are independent targets: one going down says nothing
+ // about the other, so matching on provider alone would throw away a pin for a
+ // connection that never failed.
+ const comboName = "sibling-connection-pin";
+ await settingsDb.setLKGP(comboName, comboName, "felo", "conn-A");
+
+ await clearStaleLKGP(comboName, null, comboName, null, "COMBO", undefined, {
+ provider: "felo",
+ connectionId: "conn-B",
+ });
+ assert.deepEqual(
+ await settingsDb.getLKGP(comboName, comboName),
+ { provider: "felo", connectionId: "conn-A" },
+ "conn-B failing must not clear a pin naming conn-A"
+ );
+
+ // ...and the pin IS cleared when the failure names that same connection.
+ await clearStaleLKGP(comboName, null, comboName, null, "COMBO", undefined, {
+ provider: "felo",
+ connectionId: "conn-A",
+ });
+ assert.equal(
+ await settingsDb.getLKGP(comboName, comboName),
+ null,
+ "conn-A failing must clear the pin that names it"
+ );
+});
diff --git a/tests/unit/nanobanana-image-handler.test.ts b/tests/unit/nanobanana-image-handler.test.ts
index fc438dff46..034ba6ddd7 100644
--- a/tests/unit/nanobanana-image-handler.test.ts
+++ b/tests/unit/nanobanana-image-handler.test.ts
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import dns from "node:dns";
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
+import { setPinnedFetchTestOverride } from "../../src/shared/network/remoteImageFetch.ts";
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
@@ -93,7 +94,7 @@ test("handleImageGeneration(nanobanana): async submit+poll returns URL payload",
test("handleImageGeneration(nanobanana): response_format=b64_json converts URL to b64", async () => {
const originalFetch = globalThis.fetch;
- globalThis.fetch = async (url) => {
+ const mockFetchImpl = async (url) => {
const u = String(url);
if (u.includes("/generate")) {
@@ -123,6 +124,12 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t
throw new Error(`Unexpected URL: ${u}`);
};
+ // #13883: resolveImageSource (used for the URL result → base64 conversion) now sets
+ // `pinDns: true`, which pins the connection via a real undici socket and would bypass
+ // this mocked globalThis.fetch — route it through the test-only pinned-fetch override
+ // instead (src/shared/network/remoteImageFetch.ts).
+ globalThis.fetch = mockFetchImpl;
+ setPinnedFetchTestOverride(mockFetchImpl);
try {
const result = await handleImageGeneration({
@@ -140,6 +147,7 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t
assert.equal(result.data.data[0].b64_json, "iVBORw==");
} finally {
globalThis.fetch = originalFetch;
+ setPinnedFetchTestOverride(undefined);
}
});
diff --git a/tests/unit/native-codex-auto-resume-guards.test.ts b/tests/unit/native-codex-auto-resume-guards.test.ts
new file mode 100644
index 0000000000..16d7d89eb5
--- /dev/null
+++ b/tests/unit/native-codex-auto-resume-guards.test.ts
@@ -0,0 +1,416 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+// Pure guard predicates of the native Codex auto-resume path (#13180). Split out of
+// tests/unit/native-codex-auto-resume.test.ts (which exercises the full combo flow
+// against a scratch DB) so each file stays under the 1200-line test cap; these three
+// cases need no DB, no combo config and no fixtures.
+const {
+ hasUnresolvedToolCalls,
+ hasProviderSpecificUnsafeContinuationState,
+ MAX_AUTORESUMES_PER_TURN,
+} = await import("../../open-sse/services/combo/nativeCodexTurnPin.ts");
+
+test("MAX_AUTORESUMES_PER_TURN constant is 1", () => {
+ assert.equal(MAX_AUTORESUMES_PER_TURN, 1);
+});
+
+test("hasUnresolvedToolCalls correctly validates 1:1 call-output pairs and rejects duplicates/orphans/nested", () => {
+ // Empty input: no tool calls
+ assert.equal(hasUnresolvedToolCalls({}), false);
+ assert.equal(hasUnresolvedToolCalls({ input: [] }), false);
+
+ // Nested output array with unresolved tool_use is unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ {
+ type: "function_call_output",
+ call_id: "c1",
+ output: [{ type: "tool_use", id: "tu-nested", name: "bash" }],
+ },
+ ],
+ }),
+ true
+ );
+
+ // Two calls with same call_id and two outputs with same call_id (count=2 != 1) is unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "function_call", call_id: "c-dup2", name: "cat", arguments: "{}" },
+ { type: "function_call", call_id: "c-dup2", name: "cat", arguments: "{}" },
+ { type: "function_call_output", call_id: "c-dup2", output: "out1" },
+ { type: "function_call_output", call_id: "c-dup2", output: "out2" },
+ ],
+ }),
+ true
+ );
+
+ // Two distinct calls with two distinct matching outputs is safe (false)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "function_call", call_id: "c-1", name: "cat", arguments: "{}" },
+ { type: "function_call", call_id: "c-2", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c-1", output: "out1" },
+ { type: "function_call_output", call_id: "c-2", output: "out2" },
+ ],
+ }),
+ false
+ );
+
+ // Resolved function call (1 call, 1 matching output)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "message", role: "user", content: "read file" },
+ { type: "function_call", call_id: "call-1", name: "cat", arguments: "{}" },
+ { type: "function_call_output", call_id: "call-1", output: "hello world" },
+ ],
+ }),
+ false
+ );
+
+ // Unresolved function call (call with no output)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "message", role: "user", content: "read file" },
+ { type: "function_call", call_id: "call-1", name: "cat", arguments: "{}" },
+ ],
+ }),
+ true
+ );
+
+ // Resolved custom tool call
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "message", role: "user", content: "patch file" },
+ { type: "custom_tool_call", call_id: "call-2", name: "apply_patch", input: "diff" },
+ { type: "custom_tool_call_output", call_id: "call-2", output: "ok" },
+ ],
+ }),
+ false
+ );
+
+ // Unresolved custom tool call
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "message", role: "user", content: "patch file" },
+ { type: "custom_tool_call", call_id: "call-2", name: "apply_patch", input: "diff" },
+ ],
+ }),
+ true
+ );
+
+ // Anthropic tool_use and tool_result in content array (resolved)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ messages: [
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "tu-1", name: "bash", input: {} }],
+ },
+ {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "tu-1", content: "done" }],
+ },
+ ],
+ }),
+ false
+ );
+
+ // Anthropic tool_use in content array (unresolved)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ messages: [
+ {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "tu-1", name: "bash", input: {} }],
+ },
+ ],
+ }),
+ true
+ );
+
+ // Assistant message tool_calls format (resolved)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ {
+ type: "message",
+ role: "assistant",
+ tool_calls: [{ id: "call-3", type: "function", function: { name: "shell" } }],
+ },
+ { type: "message", role: "tool", tool_call_id: "call-3", content: "done" },
+ ],
+ }),
+ false
+ );
+
+ // Assistant message tool_calls format (unresolved)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ {
+ type: "message",
+ role: "assistant",
+ tool_calls: [{ id: "call-3", type: "function", function: { name: "shell" } }],
+ },
+ ],
+ }),
+ true
+ );
+
+ // Duplicate tool call ID: two calls with same ID, one output -> unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "function_call", call_id: "call-dup", name: "cat", arguments: "{}" },
+ { type: "function_call", call_id: "call-dup", name: "cat", arguments: "{}" },
+ { type: "function_call_output", call_id: "call-dup", output: "res" },
+ ],
+ }),
+ true
+ );
+
+ // Duplicate tool output ID: one call, two outputs with same ID -> unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [
+ { type: "function_call", call_id: "call-dup-out", name: "cat", arguments: "{}" },
+ { type: "function_call_output", call_id: "call-dup-out", output: "res1" },
+ { type: "function_call_output", call_id: "call-dup-out", output: "res2" },
+ ],
+ }),
+ true
+ );
+
+ // Orphaned tool output: output without matching call -> unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [{ type: "function_call_output", call_id: "orphan-call", output: "res" }],
+ }),
+ true
+ );
+
+ // Malformed tool call with empty call_id -> unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [{ type: "function_call", call_id: "", name: "cat", arguments: "{}" }],
+ }),
+ true
+ );
+
+ // Legacy unidentifiable function_call -> unsafe (true)
+ assert.equal(
+ hasUnresolvedToolCalls({
+ input: [{ role: "assistant", function_call: { name: "test", arguments: "{}" } }],
+ }),
+ true
+ );
+});
+
+test("hasProviderSpecificUnsafeContinuationState detects opaque provider state at all levels", () => {
+ // Clean input: safe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ false
+ );
+
+ // conversation_id at root: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ conversation_id: "conv_12345",
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // conversation object at root: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ conversation: { id: "conv_67890" },
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // Item with item-level previous_response_id: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [
+ {
+ type: "message",
+ role: "assistant",
+ previous_response_id: "resp_nested_prev",
+ content: "hello",
+ },
+ ],
+ }),
+ true
+ );
+
+ // Item with item-level continuation_token: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [
+ {
+ type: "message",
+ role: "assistant",
+ continuation_token: "tok_nested_cont",
+ content: "hello",
+ },
+ ],
+ }),
+ true
+ );
+
+ // previous_response_id: unsafe (binds to upstream response store)
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ previous_response_id: "resp_12345_upstream",
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // continuation_token: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ continuation_token: "tok_opaque_blob",
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // response_id: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ response_id: "resp_999",
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // provider_metadata at root: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ provider_metadata: { openai: { message_id: "m1" } },
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // item_reference: unsafe (server-side item ID)
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [{ type: "item_reference", id: "item_abc123" }],
+ }),
+ true
+ );
+
+ // reasoning item with encrypted_content: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [
+ { type: "reasoning", encrypted_content: "enc_blob_xyz" },
+ { type: "message", role: "user", content: "hello" },
+ ],
+ }),
+ true
+ );
+
+ // thinking item with thought_signature: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [
+ { type: "thinking", thought_signature: "sig_gemini_blob" },
+ { type: "message", role: "user", content: "hello" },
+ ],
+ }),
+ true
+ );
+
+ // redacted_thinking item: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [{ type: "redacted_thinking", data: "redacted" }],
+ }),
+ true
+ );
+
+ // Nested thinking part inside content array with signature: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ messages: [
+ {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "deep thought", signature: "sig-xyz" },
+ { type: "text", text: "hello" },
+ ],
+ },
+ ],
+ }),
+ true
+ );
+
+ // encrypted_content item: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [{ type: "encrypted_content", encrypted_content: "enc_123" }],
+ }),
+ true
+ );
+
+ // Root body thoughtSignature (camelCase): unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ thoughtSignature: "sig_camel_case",
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // Root body provider_data: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ provider_data: { gemini: { candidate_token_count: 50 } },
+ input: [{ type: "message", role: "user", content: "hello" }],
+ }),
+ true
+ );
+
+ // Nested output array with encrypted_content: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [
+ {
+ type: "function_call_output",
+ call_id: "c1",
+ output: [{ type: "encrypted_content", encrypted_content: "enc_blob" }],
+ },
+ ],
+ }),
+ true
+ );
+
+ // Nested summary array with thought_signature: unsafe
+ assert.equal(
+ hasProviderSpecificUnsafeContinuationState({
+ input: [
+ {
+ type: "reasoning",
+ summary: [{ type: "summary_text", text: "...", thought_signature: "sig" }],
+ },
+ ],
+ }),
+ true
+ );
+});
diff --git a/tests/unit/native-codex-auto-resume.test.ts b/tests/unit/native-codex-auto-resume.test.ts
new file mode 100644
index 0000000000..dac462bff4
--- /dev/null
+++ b/tests/unit/native-codex-auto-resume.test.ts
@@ -0,0 +1,1131 @@
+import test, { describe, beforeEach } 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-autoresume-test-"));
+const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const { handleComboChat } = await import("../../open-sse/services/combo.ts");
+const { lockExactModel, clearAllModelLockouts } =
+ await import("../../open-sse/services/accountFallback.ts");
+const {
+ pinNativeCodexTurn,
+ advanceNativeCodexTurnGeneration,
+ getNativeCodexTurnPin,
+ getNativeCodexTurnActiveGeneration,
+ clearNativeCodexTurnPinsForTests,
+ revokeNativeCodexTurnPinsForConnection,
+ NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE,
+} = await import("../../open-sse/services/combo/nativeCodexTurnPin.ts");
+const { recordProviderCooldown, isProviderInCooldown, clearCooldownState } =
+ await import("../../open-sse/services/providerCooldownTracker.ts");
+const { PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts");
+const { getCircuitBreaker, resetAllCircuitBreakers } =
+ await import("../../src/shared/utils/circuitBreaker.ts");
+const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts");
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+
+const testSettings = {
+ resilienceSettings: {
+ providerCooldown: {
+ enabled: true,
+ minRetryCooldownMs: 5000,
+ maxRetryCooldownMs: 300000,
+ },
+ comboCooldownWait: { enabled: false },
+ },
+};
+const settings = resolveResilienceSettings(testSettings);
+
+function createLog(entries: Array<{ level: string; tag: string; msg: string }> = []) {
+ return {
+ info: (tag: string, msg: string) => entries.push({ level: "info", tag, msg }),
+ warn: (tag: string, msg: string) => entries.push({ level: "warn", tag, msg }),
+ error: (tag: string, msg: string) => entries.push({ level: "error", tag, msg }),
+ debug: (tag: string, msg: string) => entries.push({ level: "debug", tag, msg }),
+ entries,
+ };
+}
+
+async function cleanupTestDataDir() {
+ let lastError: unknown;
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ return;
+ } catch (error) {
+ lastError = error;
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ }
+ if (lastError) throw lastError;
+}
+
+test.after(async () => {
+ await cleanupTestDataDir();
+ process.env.DATA_DIR = ORIGINAL_DATA_DIR;
+});
+
+beforeEach(async () => {
+ clearAllModelLockouts();
+ clearCooldownState();
+ resetAllCircuitBreakers();
+ clearNativeCodexTurnPinsForTests();
+});
+
+describe("Native Codex Safe Auto-Resume", () => {
+ const comboName = "Codex";
+ const opusModel = "antigravity/claude-opus-4-6-thinking";
+ const geminiModel = "antigravity/gemini-3.7-flash-high";
+ const codexModel = "codex/gpt-5.5-high";
+
+ const comboConfig = {
+ name: comboName,
+ strategy: "fill-first" as const,
+ models: [opusModel, geminiModel, codexModel],
+ config: {
+ maxRetries: 0,
+ concurrencyPerModel: 1,
+ queueTimeoutMs: 1000,
+ },
+ };
+
+ test("5-Phase Production Scenario: Opus 429 Model Lockout -> Safe Auto-Resume to Gemini -> Subsequent Pinned to Gemini", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+ const conn2 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 2",
+ });
+ await providersDb.createProviderConnection({
+ provider: "codex",
+ authType: "apikey",
+ name: "Codex Key",
+ apiKey: "sk-codex-test",
+ });
+ const conn1Id = conn1.id;
+ const conn2Id = conn2.id;
+ const attemptedModels: string[] = [];
+
+ const baseTurnMetadata = {
+ thread_id: "thread-autoresume-123",
+ turn_id: "turn-autoresume-456",
+ };
+
+ // PHASE 1: Opus succeeds for turn-autoresume-456, pin created for generation 0
+ const phase1Body = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+ },
+ input: [{ type: "message", role: "user", content: "list files then edit" }],
+ };
+
+ const phase1Result = await handleComboChat({
+ body: phase1Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_body, modelStr) => {
+ attemptedModels.push(modelStr);
+ return new Response(
+ JSON.stringify({ choices: [{ message: { content: "opus output" } }] }),
+ {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ "x-omniroute-selected-connection-id": conn1Id,
+ },
+ }
+ );
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(phase1Result.ok, true);
+ assert.deepEqual(attemptedModels, [opusModel]);
+
+ const pinGen0 = getNativeCodexTurnPin(phase1Body, comboName, 0);
+ assert.ok(pinGen0, "Generation 0 pin created after Phase 1");
+ assert.equal(pinGen0.modelStr, opusModel);
+ assert.equal(pinGen0.provider, "antigravity");
+ assert.equal(pinGen0.connectionId, conn1Id);
+ assert.equal(getNativeCodexTurnActiveGeneration(phase1Body, comboName), 0);
+
+ // PHASE 2 & 3: Tool output sent for SAME turn. Opus receives 429 lockout across all connections.
+ // Safe automatic resume triggers to Gemini.
+ lockExactModel("antigravity", conn1Id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", conn2Id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ attemptedModels.length = 0;
+ const phase2LogEntries: Array<{ level: string; tag: string; msg: string }> = [];
+ const phase2Body = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+ },
+ input: [
+ { type: "message", role: "user", content: "list files then edit" },
+ { type: "function_call", call_id: "call-1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "call-1", output: "main.ts\npackage.json" },
+ ],
+ };
+
+ const phase2Result = await handleComboChat({
+ body: phase2Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_body, modelStr) => {
+ attemptedModels.push(modelStr);
+ if (modelStr === geminiModel) {
+ return new Response(
+ JSON.stringify({ choices: [{ message: { content: "gemini resumed output" } }] }),
+ {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ "x-omniroute-selected-connection-id": conn1Id,
+ },
+ }
+ );
+ }
+ return new Response(JSON.stringify({ error: "unexpected model" }), { status: 500 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(phase2LogEntries),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(phase2Result.ok, true, "Phase 2 must succeed automatically on Gemini");
+ assert.deepEqual(
+ attemptedModels,
+ [geminiModel],
+ "Only Gemini dispatched (Opus skipped due to lockout)"
+ );
+
+ // Verify telemetry logs for auto-resume
+ const eligibleLog = phase2LogEntries.find((e) =>
+ e.msg.includes("Native Codex auto-resume eligible")
+ );
+ const startedLog = phase2LogEntries.find((e) =>
+ e.msg.includes("Native Codex auto-resume started")
+ );
+ const routedLog = phase2LogEntries.find((e) =>
+ e.msg.includes("Native Codex auto-resume routed")
+ );
+ assert.ok(eligibleLog, "Should log auto-resume eligible");
+ assert.ok(startedLog, "Should log auto-resume started");
+ assert.ok(routedLog, "Should log auto-resume routed to Gemini");
+
+ // PHASE 4: Verify generation isolation: Opus gen 0 pin NOT mutated, Gemini is gen 1 pin
+ const activeGen = getNativeCodexTurnActiveGeneration(phase2Body, comboName);
+ assert.equal(activeGen, 1, "Active generation is now 1");
+
+ const gen0PinCheck = getNativeCodexTurnPin(phase2Body, comboName, 0);
+ assert.ok(gen0PinCheck);
+ assert.equal(gen0PinCheck.modelStr, opusModel, "Generation 0 pin remains Opus (not mutated)");
+
+ const gen1PinCheck = getNativeCodexTurnPin(phase2Body, comboName, 1);
+ assert.ok(gen1PinCheck);
+ assert.equal(gen1PinCheck.modelStr, geminiModel, "Generation 1 pin is Gemini");
+
+ // Active pin query without generation returns current active (Gemini)
+ const currentActivePin = getNativeCodexTurnPin(phase2Body, comboName);
+ assert.equal(currentActivePin?.modelStr, geminiModel);
+
+ // PHASE 5: Subsequent tool output for SAME turn stays pinned to Gemini
+ attemptedModels.length = 0;
+ const phase3Body = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+ },
+ input: [
+ { type: "message", role: "user", content: "list files then edit" },
+ { type: "function_call", call_id: "call-1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "call-1", output: "main.ts\npackage.json" },
+ { type: "function_call", call_id: "call-2", name: "cat", arguments: '{"file":"main.ts"}' },
+ { type: "function_call_output", call_id: "call-2", output: "console.log('hi')" },
+ ],
+ };
+
+ const phase3Result = await handleComboChat({
+ body: phase3Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_body, modelStr) => {
+ attemptedModels.push(modelStr);
+ return new Response(
+ JSON.stringify({ choices: [{ message: { content: "gemini step 2 output" } }] }),
+ {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ "x-omniroute-selected-connection-id": conn1Id,
+ },
+ }
+ );
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(phase3Result.ok, true);
+ assert.deepEqual(attemptedModels, [geminiModel], "Subsequent request stayed pinned to Gemini");
+ });
+
+ test("Opaque continuation state (previous_response_id) rejects auto-resume and returns HTTP 400", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+
+ const baseTurnMetadata = {
+ thread_id: "thread-unsafe-state",
+ turn_id: "turn-unsafe-state",
+ };
+
+ // Phase 1: Opus succeeds
+ const phase1Body = {
+ stream: false,
+ client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+ input: [{ type: "message", role: "user", content: "hello" }],
+ };
+
+ await handleComboChat({
+ body: phase1Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Lock Opus
+ lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ // Request with previous_response_id
+ const unsafeBody = {
+ stream: false,
+ previous_response_id: "resp_opus_pinned_upstream",
+ client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+ input: [
+ { type: "message", role: "user", content: "hello" },
+ { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c1", output: "ok" },
+ ],
+ };
+
+ const attempted: string[] = [];
+ const logs: Array<{ level: string; tag: string; msg: string }> = [];
+ const result = await handleComboChat({
+ body: unsafeBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(logs),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(result.status, 400);
+ const data = await result.json();
+ assert.equal(data.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+ assert.equal(attempted.length, 0, "No model dispatched");
+
+ const rejectLog = logs.find(
+ (e) => e.msg.includes("auto-resume rejected") && e.msg.includes("unsafe_provider_state")
+ );
+ assert.ok(rejectLog, "Should log rejection reason unsafe_provider_state");
+ });
+
+ test("Pending tool call prevents auto-resume and returns HTTP 400", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+
+ const baseTurnMetadata = {
+ thread_id: "thread-pending-123",
+ turn_id: "turn-pending-456",
+ };
+
+ // Phase 1: Opus succeeds
+ const phase1Body = {
+ stream: false,
+ client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+ input: [{ type: "message", role: "user", content: "hello" }],
+ };
+
+ await handleComboChat({
+ body: phase1Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Lock Opus
+ lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ // Request with UNRESOLVED tool call (missing tool output)
+ const pendingToolBody = {
+ stream: false,
+ client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+ input: [
+ { type: "message", role: "user", content: "hello" },
+ { type: "function_call", call_id: "call-unresolved", name: "shell", arguments: "{}" },
+ ],
+ };
+
+ const attempted: string[] = [];
+ const logs: Array<{ level: string; tag: string; msg: string }> = [];
+ const result = await handleComboChat({
+ body: pendingToolBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(logs),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(result.status, 400, "Must return HTTP 400 when tool call unresolved");
+ const data = await result.json();
+ assert.equal(data.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+ assert.equal(attempted.length, 0, "No model dispatched");
+
+ const rejectLog = logs.find(
+ (e) => e.msg.includes("auto-resume rejected") && e.msg.includes("pending_tool_call")
+ );
+ assert.ok(rejectLog, "Should log rejection reason pending_tool_call");
+ });
+
+ test("Partial stream safety: Opus emits partial SSE stream chunks then fails -> Gemini is NOT dispatched mid-stream", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+
+ const baseTurnMetadata = {
+ thread_id: "thread-partial-stream-safety",
+ turn_id: "turn-partial-stream-safety",
+ };
+
+ // Phase 1: Opus succeeds
+ const phase1Body = {
+ stream: true,
+ client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+ input: [{ type: "message", role: "user", content: "hello" }],
+ };
+
+ await handleComboChat({
+ body: phase1Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Phase 2: Request is sent. Opus is NOT locked before dispatch.
+ // Opus returns a stream that emits partial bytes and then aborts/fails.
+ // Invariant: Gemini MUST NOT be dispatched during this request.
+ const phase2Body = {
+ stream: true,
+ client_metadata: { "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata) },
+ input: [
+ { type: "message", role: "user", content: "hello" },
+ { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c1", output: "ok" },
+ ],
+ };
+
+ const attempted: string[] = [];
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ new TextEncoder().encode('data: {"choices":[{"delta":{"content":"partial output"}}]}\n\n')
+ );
+ controller.error(new Error("Mid-stream connection reset"));
+ },
+ });
+
+ const _result = await handleComboChat({
+ body: phase2Body,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ return new Response(stream, {
+ status: 200,
+ headers: { "content-type": "text/event-stream" },
+ });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Pinned turn with maxRetries=0 dispatches strictly Opus; Gemini must NOT be called
+ assert.deepEqual(attempted, [opusModel], "Only pinned Opus dispatched; Gemini never called");
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(phase2Body, comboName),
+ 0,
+ "Generation remains 0 on runtime failure"
+ );
+ });
+
+ test("Sibling connection is preferred over auto-resume", async () => {
+ const conn1Id = "conn-sib-1";
+ const conn2Id = "conn-sib-2";
+
+ const explicitComboConfig = {
+ name: comboName,
+ strategy: "fill-first" as const,
+ models: [
+ { id: "s1", kind: "model" as const, model: opusModel, connectionId: conn1Id, weight: 1 },
+ { id: "s2", kind: "model" as const, model: opusModel, connectionId: conn2Id, weight: 1 },
+ { id: "s3", kind: "model" as const, model: geminiModel, connectionId: conn1Id, weight: 1 },
+ ],
+ config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
+ };
+
+ const turnBody = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify({
+ thread_id: "thread-sib",
+ turn_id: "turn-sib",
+ }),
+ },
+ input: [{ type: "message", role: "user", content: "test" }],
+ };
+
+ // Phase 1: Opus succeeds on conn1
+ await handleComboChat({
+ body: turnBody,
+ combo: explicitComboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus conn1" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1Id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Lock ONLY conn1 Opus; conn2 remains healthy
+ lockExactModel("antigravity", conn1Id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ const attempted: Array<{ modelStr: string; connectionId?: string }> = [];
+ const result = await handleComboChat({
+ body: turnBody,
+ combo: explicitComboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_body, modelStr, target) => {
+ attempted.push({ modelStr, connectionId: target?.connectionId || undefined });
+ return new Response(JSON.stringify({ choices: [{ message: { content: "opus conn2" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn2Id },
+ });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(result.ok, true);
+ assert.equal(attempted.length, 1);
+ assert.equal(attempted[0].modelStr, opusModel, "Opus remains pinned to sibling connection");
+ assert.equal(attempted[0].connectionId, conn2Id, "Connection failed over to conn2");
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(turnBody, comboName),
+ 0,
+ "No generation advance on sibling failover"
+ );
+ });
+
+ test("No healthy alternate model in combo returns HTTP 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE and does NOT advance generation", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+
+ const singleModelComboConfig = {
+ name: "OpusOnly",
+ strategy: "fill-first" as const,
+ models: [opusModel],
+ config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
+ };
+
+ const turnBody = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify({
+ thread_id: "thread-single",
+ turn_id: "turn-single",
+ }),
+ },
+ input: [{ type: "message", role: "user", content: "test" }],
+ };
+
+ // Phase 1: Opus succeeds
+ await handleComboChat({
+ body: turnBody,
+ combo: singleModelComboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Lock Opus
+ lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ const attempted: string[] = [];
+ const result = await handleComboChat({
+ body: turnBody,
+ combo: singleModelComboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(result.status, 400);
+ const data = await result.json();
+ assert.equal(data.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+ assert.equal(attempted.length, 0);
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(turnBody, "OpusOnly"),
+ 0,
+ "Generation must not advance when no alternate target exists"
+ );
+ });
+
+ test("Provider circuit breaker OPEN does NOT trigger auto-resume", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+
+ const turnBody = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify({
+ thread_id: "thread-cb",
+ turn_id: "turn-cb",
+ }),
+ },
+ input: [
+ { type: "message", role: "user", content: "cmd" },
+ { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c1", output: "ok" },
+ ],
+ };
+
+ // Phase 1: Opus succeeds
+ await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Trip provider circuit breaker (provider-wide failure)
+ const cb = getCircuitBreaker("antigravity", { failureThreshold: 1, resetTimeout: 60000 });
+ try {
+ await cb.execute(async () => {
+ throw new Error("simulated 503");
+ });
+ } catch {
+ // expected
+ }
+ assert.equal(cb.getStatus().state, "OPEN");
+
+ const attempted: string[] = [];
+ const result = await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(result.ok, false, "Should fail due to provider circuit breaker OPEN");
+ assert.equal(attempted.length, 0, "No targets attempted");
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(turnBody, comboName),
+ 0,
+ "Circuit breaker does not advance generation"
+ );
+ });
+
+ test("Provider global cooldown does NOT trigger auto-resume", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+
+ const turnBody = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify({
+ thread_id: "thread-cd",
+ turn_id: "turn-cd",
+ }),
+ },
+ input: [
+ { type: "message", role: "user", content: "cmd" },
+ { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c1", output: "ok" },
+ ],
+ };
+
+ // Phase 1: Opus succeeds
+ await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ // Trigger provider global cooldown.
+ // Current upstream requires providerFailureThreshold failures before
+ // the whole provider is considered cooling.
+ for (let i = 0; i < PROVIDER_PROFILES.oauth.providerFailureThreshold; i += 1) {
+ recordProviderCooldown("antigravity", undefined, settings);
+ }
+ assert.equal(isProviderInCooldown("antigravity", undefined, settings), true);
+
+ const attempted: string[] = [];
+ const result = await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(attempted.length, 0);
+ assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 0);
+ });
+
+ test("MAX_AUTORESUMES_PER_TURN = 1 stops cascading: Opus -> Gemini succeeds, but second failure in same turn returns terminal 400", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+ await providersDb.createProviderConnection({
+ provider: "codex",
+ authType: "apikey",
+ name: "Codex Key",
+ apiKey: "sk-codex-test",
+ });
+
+ const baseTurnMetadata = {
+ thread_id: "thread-max-cascade-1",
+ turn_id: "turn-max-cascade-1",
+ };
+
+ const turnBody = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+ },
+ input: [
+ { type: "message", role: "user", content: "cascade test" },
+ { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c1", output: "ok" },
+ ],
+ };
+
+ // Gen 0: Opus succeeds
+ await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+ assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 0);
+
+ // Lock Opus -> 1st auto-resume to Gemini (Gen 1) SUCCEEDS
+ lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ const resGen1 = await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ if (m === geminiModel) {
+ return new Response(JSON.stringify({ choices: [{ message: { content: "gemini" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ });
+ }
+ return new Response(JSON.stringify({ error: "fail" }), { status: 500 });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(resGen1.ok, true);
+ assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 1);
+
+ // Now lock Gemini as well in the SAME turn: 2nd auto-resume MUST BE REJECTED (policy = 1)
+ lockExactModel("antigravity", conn1.id, "gemini-3.7-flash-high", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "gemini-3.7-flash-high", "quota_exhausted", 60_000);
+
+ const attemptedGen2: string[] = [];
+ const logsGen2: Array<{ level: string; tag: string; msg: string }> = [];
+ const resGen2 = await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attemptedGen2.push(m);
+ return new Response(JSON.stringify({ choices: [{ message: { content: "codex" } }] }), {
+ status: 200,
+ });
+ },
+ isModelAvailable: async () => true,
+ log: createLog(logsGen2),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(resGen2.status, 400, "Must return HTTP 400: max resumes exceeded");
+ const dataGen2 = await resGen2.json();
+ assert.equal(dataGen2.error.code, NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE_CODE);
+ assert.equal(attemptedGen2.length, 0, "Codex must NOT be dispatched on 2nd cascade");
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(turnBody, comboName),
+ 1,
+ "Generation remains 1"
+ );
+
+ const maxLog = logsGen2.find(
+ (e) => e.msg.includes("auto-resume rejected") && e.msg.includes("max_resumes_exceeded")
+ );
+ assert.ok(maxLog, "Should log max_resumes_exceeded rejection");
+ });
+
+ test("Pin immutability, multi-generation revocation, and TTL expiry cleanup", () => {
+ const mockBody = {
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify({
+ thread_id: "thread-immutability",
+ turn_id: "turn-immutability",
+ }),
+ },
+ };
+
+ // Pin Generation 0 on conn-A
+ pinNativeCodexTurn({
+ body: mockBody,
+ comboName,
+ target: {
+ kind: "model",
+ stepId: "s1",
+ executionKey: "ek1",
+ modelStr: opusModel,
+ provider: "antigravity",
+ providerId: null,
+ connectionId: "conn-A",
+ weight: 1,
+ label: null,
+ },
+ connectionId: "conn-A",
+ });
+
+ const gen0Pin = getNativeCodexTurnPin(mockBody, comboName, 0);
+ assert.equal(gen0Pin?.modelStr, opusModel);
+ assert.equal(gen0Pin?.connectionId, "conn-A");
+
+ // Advance to Gen 1 and pin on conn-B
+ advanceNativeCodexTurnGeneration(mockBody, comboName);
+ pinNativeCodexTurn({
+ body: mockBody,
+ comboName,
+ target: {
+ kind: "model",
+ stepId: "s2",
+ executionKey: "ek2",
+ modelStr: geminiModel,
+ provider: "antigravity",
+ providerId: null,
+ connectionId: "conn-B",
+ weight: 1,
+ label: null,
+ },
+ connectionId: "conn-B",
+ });
+
+ // Verify Gen 0 is still Opus on conn-A (immutability check)
+ const gen0Check = getNativeCodexTurnPin(mockBody, comboName, 0);
+ assert.equal(gen0Check?.modelStr, opusModel);
+ assert.equal(gen0Check?.connectionId, "conn-A");
+
+ // Verify Gen 1 is Gemini on conn-B
+ const gen1Check = getNativeCodexTurnPin(mockBody, comboName, 1);
+ assert.equal(gen1Check?.modelStr, geminiModel);
+ assert.equal(gen1Check?.connectionId, "conn-B");
+
+ // Revoke pins for conn-A only: Gen 0 is deleted, Gen 1 is intact
+ const revokedConnA = revokeNativeCodexTurnPinsForConnection("conn-A");
+ assert.equal(revokedConnA, 1);
+ assert.equal(getNativeCodexTurnPin(mockBody, comboName, 0), null);
+ assert.equal(getNativeCodexTurnPin(mockBody, comboName, 1)?.modelStr, geminiModel);
+
+ // Revoke pins for conn-B: Gen 1 is deleted, turn record is fully removed
+ const revokedConnB = revokeNativeCodexTurnPinsForConnection("conn-B");
+ assert.equal(revokedConnB, 1);
+ assert.equal(getNativeCodexTurnPin(mockBody, comboName, 1), null);
+ assert.equal(getNativeCodexTurnActiveGeneration(mockBody, comboName), 0);
+ });
+
+ test("Auto-resume dispatch failure does not advance generation or cascade to 3rd model", async () => {
+ const conn1 = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity Account 1",
+ });
+ await providersDb.createProviderConnection({
+ provider: "codex",
+ authType: "apikey",
+ name: "Codex Key",
+ apiKey: "sk-codex-test",
+ });
+
+ const baseTurnMetadata = {
+ thread_id: "thread-fail-no-cascade",
+ turn_id: "turn-fail-no-cascade",
+ };
+
+ const turnBody = {
+ stream: false,
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify(baseTurnMetadata),
+ },
+ input: [
+ { type: "message", role: "user", content: "cmd" },
+ { type: "function_call", call_id: "c1", name: "ls", arguments: "{}" },
+ { type: "function_call_output", call_id: "c1", output: "ok" },
+ ],
+ };
+
+ // Phase 1: Opus succeeds (Gen 0)
+ await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async () =>
+ new Response(JSON.stringify({ choices: [{ message: { content: "opus" } }] }), {
+ status: 200,
+ headers: { "x-omniroute-selected-connection-id": conn1.id },
+ }),
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+ assert.equal(getNativeCodexTurnActiveGeneration(turnBody, comboName), 0);
+
+ // Lock Opus
+ lockExactModel("antigravity", conn1.id, "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+ lockExactModel("antigravity", "", "claude-opus-4-6-thinking", "quota_exhausted", 60_000);
+
+ // Phase 2: Auto-resume routes to Gemini, but Gemini upstream fails (500)
+ // Invariant: Codex (3rd model) MUST NOT be dispatched in this same request!
+ const attempted: string[] = [];
+ const res = await handleComboChat({
+ body: turnBody,
+ combo: comboConfig,
+ clientManagedResponsesContext: true,
+ handleSingleModel: async (_b, m) => {
+ attempted.push(m);
+ if (m === geminiModel) {
+ return new Response(JSON.stringify({ error: "gemini temporary 500" }), { status: 500 });
+ }
+ return new Response(
+ JSON.stringify({ choices: [{ message: { content: "codex leaked" } }] }),
+ {
+ status: 200,
+ }
+ );
+ },
+ isModelAvailable: async () => true,
+ log: createLog(),
+ settings: testSettings,
+ allCombos: null,
+ });
+
+ assert.equal(res.ok, false);
+ assert.deepEqual(attempted, [geminiModel], "Only Gemini attempted; no cascade to Codex");
+ // Because Gemini failed, active generation was NOT committed to 1
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(turnBody, comboName),
+ 0,
+ "Generation remains 0 on dispatch failure"
+ );
+ assert.equal(
+ getNativeCodexTurnPin(turnBody, comboName, 0)?.modelStr,
+ opusModel,
+ "Gen 0 pin remains Opus"
+ );
+ });
+
+ test("TTL expiry cleans up turn record and prevents memory leak", () => {
+ const mockBody = {
+ client_metadata: {
+ "x-codex-turn-metadata": JSON.stringify({
+ thread_id: "thread-ttl-test",
+ turn_id: "turn-ttl-test",
+ }),
+ },
+ };
+
+ pinNativeCodexTurn({
+ body: mockBody,
+ comboName,
+ target: {
+ kind: "model",
+ stepId: "s1",
+ executionKey: "ek1",
+ modelStr: opusModel,
+ provider: "antigravity",
+ providerId: null,
+ connectionId: "conn-ttl",
+ weight: 1,
+ label: null,
+ },
+ connectionId: "conn-ttl",
+ });
+
+ assert.ok(getNativeCodexTurnPin(mockBody, comboName));
+
+ // Advance Date.now past TTL_MS (45 minutes = 2_700_000 ms)
+ const origDateNow = Date.now;
+ try {
+ Date.now = () => origDateNow() + 46 * 60 * 1000;
+ // Prune is triggered on read
+ assert.equal(getNativeCodexTurnPin(mockBody, comboName), null, "Expired pin pruned");
+ assert.equal(
+ getNativeCodexTurnActiveGeneration(mockBody, comboName),
+ 0,
+ "Expired turn record pruned"
+ );
+ } finally {
+ Date.now = origDateNow;
+ }
+ });
+});
diff --git a/tests/unit/pindns-toctou-13883.test.ts b/tests/unit/pindns-toctou-13883.test.ts
new file mode 100644
index 0000000000..194746d7dc
--- /dev/null
+++ b/tests/unit/pindns-toctou-13883.test.ts
@@ -0,0 +1,81 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import dns from "node:dns";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-pindns-13883-"));
+
+// #13883 — security: pinDns off at the three new public-only image fetch sites let a
+// DNS-rebinding hostname (public at validation time, private at real connect time) bypass
+// the `guard: "public-only"` check, since an un-pinned fetch performs its own, independent
+// DNS resolution. `resolveImageSource` / `normalizeNanoBananaTaskResult` (imageGeneration.ts)
+// and `resolveUpscaleImageSource` (imageUpscale/shared.ts) now all set `pinDns: true`, which
+// closes the gap by binding the connection to the single validated DNS answer instead of
+// letting the transport re-resolve it — see `src/shared/network/dnsPinnedFetch.ts`.
+//
+// None of these three call sites expose a `lookup` injection point (they always use the
+// real resolver), so this regression guard resolves a fake hostname to a public-looking,
+// deliberately unreachable TEST-NET-3 address (RFC 5737 — never routed on the public
+// internet) and asserts each site's request goes out through the real pinned undici socket
+// (and therefore fails closed against that unreachable address) rather than through a
+// mocked `globalThis.fetch`. If a future change dropped `pinDns: true` at any of these
+// sites, the un-pinned `fetch()` call would hit the mock below instead — turning this red.
+
+function withPublicDns(run: () => Promise): Promise {
+ const original = dns.promises.lookup;
+ (dns.promises as { lookup: unknown }).lookup = (async (
+ _hostname: string,
+ options?: { all?: boolean }
+ ) => {
+ const record = { address: "203.0.113.7", family: 4 }; // RFC 5737 TEST-NET-3: unreachable
+ return options && options.all ? [record] : record;
+ }) as typeof dns.promises.lookup;
+ return run().finally(() => {
+ (dns.promises as { lookup: unknown }).lookup = original;
+ });
+}
+
+const { resolveImageSource, normalizeNanoBananaTaskResult } =
+ await import("../../open-sse/handlers/imageGeneration.ts");
+const { resolveUpscaleImageSource } =
+ await import("../../open-sse/handlers/imageUpscale/shared.ts");
+
+/** Runs `attempt` with a mocked `globalThis.fetch` that must never be reached when
+ * `pinDns: true` is wired correctly, and confirms the call still fails closed (the pinned
+ * connection targets an unreachable address instead of falling back to the mock). */
+async function assertPinnedNotMocked(attempt: () => Promise): Promise {
+ let mockCalled = false;
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (async () => {
+ mockCalled = true;
+ throw new Error("globalThis.fetch must not be reached when pinDns is active");
+ }) as typeof fetch;
+
+ try {
+ await assert.rejects(() => withPublicDns(attempt));
+ assert.equal(mockCalled, false, "pinDns must bypass globalThis.fetch, not call it");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+}
+
+test("resolveImageSource (imageGeneration.ts) fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
+ await assertPinnedNotMocked(() => resolveImageSource("https://rebind-13883.example.com/x.png"));
+});
+
+test("normalizeNanoBananaTaskResult result-URL download fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
+ const taskData = {
+ response: { resultImageUrl: "https://rebind-13883.example.com/result.png" },
+ };
+ await assertPinnedNotMocked(() =>
+ normalizeNanoBananaTaskResult(taskData, { response_format: "b64_json" }, null)
+ );
+});
+
+test("resolveUpscaleImageSource (imageUpscale/shared.ts) fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
+ await assertPinnedNotMocked(() =>
+ resolveUpscaleImageSource("https://rebind-13883.example.com/source.png")
+ );
+});
diff --git a/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts b/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts
index f20ce61210..2f494599d2 100644
--- a/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts
+++ b/tests/unit/provider-limits-local-apikey-sync-spacing.test.ts
@@ -78,8 +78,11 @@ test("syncAllProviderLimits spaces chunks for local/API-key connections when spa
const chunkStarts: number[] = [];
const start = Date.now();
- globalThis.fetch = (async () => {
- chunkStarts.push(Date.now() - start);
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ // #12754 added a second per-connection call (customer-package-reset/list)
+ // after the quota fetch. A chunk starts at the QUOTA request; counting every
+ // fetch would read the reset-card follow-up as a fourth-sixth chunk.
+ if (String(input).includes("/quota/limit")) chunkStarts.push(Date.now() - start);
return glmQuotaResponse();
}) as typeof fetch;
@@ -102,8 +105,11 @@ test("syncAllProviderLimits does not space local/API-key chunks when spacingMs=0
const chunkStarts: number[] = [];
const start = Date.now();
- globalThis.fetch = (async () => {
- chunkStarts.push(Date.now() - start);
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ // #12754 added a second per-connection call (customer-package-reset/list)
+ // after the quota fetch. A chunk starts at the QUOTA request; counting every
+ // fetch would read the reset-card follow-up as a fourth-sixth chunk.
+ if (String(input).includes("/quota/limit")) chunkStarts.push(Date.now() - start);
return glmQuotaResponse();
}) as typeof fetch;
diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts
index a8d6d2dd80..a8092fd0af 100644
--- a/tests/unit/provider-models-route.test.ts
+++ b/tests/unit/provider-models-route.test.ts
@@ -981,7 +981,7 @@ test("provider models route retries Antigravity discovery endpoints before retur
{ id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" },
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" },
- { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash High" },
+ { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" },
]);
});
diff --git a/tests/unit/provider-request-failure-pipeline.test.ts b/tests/unit/provider-request-failure-pipeline.test.ts
index d27e2f361b..3f0b1a3000 100644
--- a/tests/unit/provider-request-failure-pipeline.test.ts
+++ b/tests/unit/provider-request-failure-pipeline.test.ts
@@ -21,7 +21,8 @@ const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"
const { resetAll: resetAccountSemaphores } =
await import("../../open-sse/services/accountSemaphore.ts");
const { clearModelLock } = await import("../../open-sse/services/accountFallback.ts");
-const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLogs.ts");
+const { getCallLogs, getCallLogById, waitForCallLogSaves } =
+ await import("../../src/lib/usage/callLogs.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { resetPayloadRulesConfigForTests } = await import("../../open-sse/services/payloadRules.ts");
const { CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, CONTEXT_1M_BETA_HEADER } =
@@ -56,6 +57,11 @@ async function resetStorage() {
clearIdempotency();
clearInflight();
clearModelLock();
+ // Call-log persistence is fire-and-forget and the first cold artifact-worker
+ // spawn can take ~2.4s, so this test's saves may still be in flight when the
+ // next test resets the DB. Drain so a late row cannot land in the next test's
+ // fresh database and get picked up by its waitFor(getLatestCallLog()) (#12780).
+ await waitForCallLogSaves(10_000);
core.resetDbInstance();
// A full reset must also drop the settings read-cache. Otherwise the cached
// value (e.g. call_log_pipeline_enabled=true seeded earlier) survives the DB
diff --git a/tests/unit/quota-signal-errortext-threading.test.ts b/tests/unit/quota-signal-errortext-threading.test.ts
new file mode 100644
index 0000000000..146063dcf4
--- /dev/null
+++ b/tests/unit/quota-signal-errortext-threading.test.ts
@@ -0,0 +1,202 @@
+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";
+import { fileURLToPath } from "node:url";
+
+// #10460 pattern: DATA_DIR must be assigned BEFORE any transitive DB import.
+// accountFallback.ts statically imports `@/lib/db/providers` -> `src/lib/db/core.ts`,
+// whose DATA_DIR is captured once at module-load time.
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-errortext-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "quota-errortext-test-secret";
+
+const { shouldMarkAccountExhaustedFrom429 } =
+ await import("../../open-sse/services/accountFallback.ts");
+
+/**
+ * `shouldPreserveQuotaSignals(provider, errorText)` (open-sse/services/quotaResetParsing.ts)
+ * gained its second parameter with the #6638 fix, but only ONE of its two call sites was
+ * updated: `checkFallbackError` passes `errorText`, while
+ * `shouldMarkAccountExhaustedFrom429` still called it with the provider alone. With
+ * `errorText` undefined the helper's `Boolean(errorText) && looksLikeQuotaExhausted(...)`
+ * branch can never be true, so for every apikey-category provider the quota cache was
+ * never marked exhausted — even when the upstream body explicitly said a long-window cap
+ * was hit. These cases pin both directions of the now-threaded argument.
+ */
+
+// An explicit long-window quota body — the exact shape #6638 was reported with.
+const QUOTA_EXHAUSTED_BODY = JSON.stringify({
+ error: "You have exceeded your weekly usage quota. Your quota will reset in 3 days.",
+});
+
+test("shouldMarkAccountExhaustedFrom429 seeds the quota cache for an apikey 429 whose body says the quota is exhausted", () => {
+ // `openai` is apikey-category and has no per-model quota, so the result is decided
+ // purely by whether the body-text quota signal reaches shouldPreserveQuotaSignals.
+ assert.equal(
+ shouldMarkAccountExhaustedFrom429(
+ "openai",
+ "gpt-4o-mini",
+ undefined,
+ undefined,
+ QUOTA_EXHAUSTED_BODY
+ ),
+ true
+ );
+ assert.equal(
+ shouldMarkAccountExhaustedFrom429(
+ "anthropic",
+ "claude-sonnet-4-6",
+ undefined,
+ undefined,
+ QUOTA_EXHAUSTED_BODY
+ ),
+ true
+ );
+});
+
+test("shouldMarkAccountExhaustedFrom429 still ignores a plain apikey rate limit", () => {
+ // Neither body matches QUOTA_PATTERNS, so a plain 429 must keep falling through to the
+ // short generic cooldown instead of poisoning the connection's quota cache.
+ assert.equal(
+ shouldMarkAccountExhaustedFrom429(
+ "openai",
+ "gpt-4o-mini",
+ undefined,
+ undefined,
+ "Rate limit exceeded, retry in 20s"
+ ),
+ false
+ );
+ assert.equal(
+ shouldMarkAccountExhaustedFrom429(
+ "openai",
+ "gpt-4o-mini",
+ undefined,
+ undefined,
+ "Too Many Requests"
+ ),
+ false
+ );
+});
+
+test("shouldMarkAccountExhaustedFrom429 keeps its pre-existing behavior when no errorText is supplied", () => {
+ // The new parameter is optional and additive: OAuth-category providers still preserve
+ // quota signals unconditionally, and apikey-category ones still default to "not
+ // exhausted" without an explicit body signal.
+ assert.equal(shouldMarkAccountExhaustedFrom429("claude", "claude-sonnet-4-6"), true);
+ assert.equal(shouldMarkAccountExhaustedFrom429("openai", "gpt-4o-mini"), false);
+});
+
+test("shouldMarkAccountExhaustedFrom429 lets a transient failureKind win over a quota body", () => {
+ // The failureKind short-circuit runs before the body-text check and must stay that way:
+ // a 429 the classifier already called transient never poisons the quota cache.
+ assert.equal(
+ shouldMarkAccountExhaustedFrom429(
+ "openai",
+ "gpt-4o-mini",
+ undefined,
+ "rate_limit",
+ QUOTA_EXHAUSTED_BODY
+ ),
+ false
+ );
+ assert.equal(
+ shouldMarkAccountExhaustedFrom429(
+ "openai",
+ "gpt-4o-mini",
+ undefined,
+ "transient",
+ QUOTA_EXHAUSTED_BODY
+ ),
+ false
+ );
+});
+
+/**
+ * The cases above pin the helper. This one pins the WIRING, and it is the reason the
+ * fix does anything in production.
+ *
+ * `errorText` is an OPTIONAL 5th parameter, so dropping it at the call site is neither a
+ * type error nor a helper-test failure — exactly the shape of the bug being fixed (a
+ * two-argument helper whose call site silently passes one). Without this case the
+ * production half of the patch could be reverted, or lost in a refactor, with the whole
+ * suite green.
+ *
+ * `handleSingleModelChat` is not exported from `src/sse/handlers/chat.ts`, so the call
+ * cannot be driven or spied without changing the production surface. A source-level
+ * assertion is the precedent for that situation in this suite — see
+ * `tests/unit/api-key-provider-quota-bypass-scope.test.ts`. Parse the argument list
+ * rather than regex-matching the formatted text, so Prettier reflowing the call cannot
+ * turn this guard into a false failure (or, worse, a false pass).
+ */
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+
+/** Top-level (paren/bracket/brace-depth 0) comma split of one argument list. */
+function splitTopLevelArgs(argList: string): string[] {
+ const args: string[] = [];
+ let depth = 0;
+ let current = "";
+ for (const ch of argList) {
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
+ if (ch === "," && depth === 0) {
+ args.push(current.trim());
+ current = "";
+ continue;
+ }
+ current += ch;
+ }
+ if (current.trim().length > 0) args.push(current.trim());
+ return args;
+}
+
+/** Every `fn(...)` call in `source`, returned as its list of top-level arguments. */
+function callSiteArgs(source: string, fn: string): string[][] {
+ const calls: string[][] = [];
+ const needle = `${fn}(`;
+ let from = 0;
+ for (;;) {
+ const start = source.indexOf(needle, from);
+ if (start === -1) break;
+ from = start + needle.length;
+ // Skip the import/declaration forms — only real invocations carry arguments.
+ const before = source.slice(Math.max(0, start - 9), start);
+ if (/\bfunction\s+$/.test(before)) continue;
+ let depth = 1;
+ let i = from;
+ while (i < source.length && depth > 0) {
+ const ch = source[i];
+ if (ch === "(") depth++;
+ else if (ch === ")") depth--;
+ i++;
+ }
+ calls.push(splitTopLevelArgs(source.slice(from, i - 1)));
+ }
+ return calls;
+}
+
+test("chat.ts forwards the upstream body as the 5th argument to shouldMarkAccountExhaustedFrom429", () => {
+ const source = fs.readFileSync(path.join(repoRoot, "src/sse/handlers/chat.ts"), "utf8");
+ const calls = callSiteArgs(source, "shouldMarkAccountExhaustedFrom429").filter(
+ // Drop the `import { … }` specifier, which parses as a zero-argument "call".
+ (args) => args.length > 0
+ );
+
+ assert.equal(
+ calls.length,
+ 1,
+ "expected exactly one shouldMarkAccountExhaustedFrom429 call site in chat.ts; " +
+ "a new one must forward errorText too"
+ );
+ assert.deepEqual(calls[0], ["provider", "model", "passthroughModels", "failureKind", "errorStr"]);
+
+ // Pin what `errorStr` is, so the guard cannot pass on a same-named local that no longer
+ // holds the upstream body (chat.ts:2282).
+ assert.match(source, /const errorStr = String\(result\.rawMessage \?\? result\.error \?\? ""\);/);
+});
+
+test.after(() => {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
diff --git a/tests/unit/rate-limit-learned-cap-13594.test.ts b/tests/unit/rate-limit-learned-cap-13594.test.ts
new file mode 100644
index 0000000000..e826fc2216
--- /dev/null
+++ b/tests/unit/rate-limit-learned-cap-13594.test.ts
@@ -0,0 +1,444 @@
+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-learned-cap-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+// Dynamic imports are required because DATA_DIR must be set before DB modules evaluate.
+await import("../../src/lib/db/core.ts");
+const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const requestCapModule = await import("../../open-sse/services/rateLimitManager/requestCap.ts");
+const { parseRequestCapFromBody } = requestCapModule;
+const { classifyErrorText } = await import("../../open-sse/services/accountFallback.ts");
+const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
+const { findMatchingErrorRule } = await import("../../open-sse/config/errorConfig.ts");
+const { STANDARD_HEADERS } = await import("../../open-sse/services/rateLimitManager/headers.ts");
+const { DEFAULT_RESILIENCE_SETTINGS } = await import("../../src/lib/resilience/settings.ts");
+const Bottleneck = (await import("bottleneck")).default;
+
+const TOKENROUTER_429 = JSON.stringify({
+ error: {
+ message: "You have reached the request limit: Maximum 5 requests within 1 minutes",
+ type: "rate_limit_error",
+ },
+});
+
+type Captured = {
+ options: Record;
+ updates: Record[];
+};
+
+function captureLimiters(): Captured[] {
+ const captured: Captured[] = [];
+ rateLimitManager.__setLimiterFactoryForTests((options) => {
+ const limiter = new Bottleneck(options);
+ const entry: Captured = { options: { ...options }, updates: [] };
+ const original = limiter.updateSettings.bind(limiter);
+ limiter.updateSettings = (updates) => {
+ entry.updates.push({ ...updates });
+ return original(updates);
+ };
+ captured.push(entry);
+ return limiter;
+ });
+ return captured;
+}
+
+test.beforeEach(async () => {
+ await rateLimitManager.__resetRateLimitManagerForTests();
+});
+
+test.after(async () => {
+ await rateLimitManager.__resetRateLimitManagerForTests();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+test("parseRequestCapFromBody reads hard request caps from 429 bodies", () => {
+ assert.deepEqual(parseRequestCapFromBody(TOKENROUTER_429), { requests: 5, windowMs: 60_000 });
+ assert.deepEqual(parseRequestCapFromBody(JSON.parse(TOKENROUTER_429)), {
+ requests: 5,
+ windowMs: 60_000,
+ });
+ assert.deepEqual(parseRequestCapFromBody("429: Maximum 100 requests within 30 seconds"), {
+ requests: 100,
+ windowMs: 30_000,
+ });
+ assert.deepEqual(parseRequestCapFromBody("Rate limit exceeded: 60 requests per minute"), {
+ requests: 60,
+ windowMs: 60_000,
+ });
+ assert.deepEqual(parseRequestCapFromBody("You hit the limit of 10 requests per 2 minutes"), {
+ requests: 10,
+ windowMs: 120_000,
+ });
+ assert.deepEqual(parseRequestCapFromBody("Rate limit: 20 RPM"), {
+ requests: 20,
+ windowMs: 60_000,
+ });
+ assert.deepEqual(parseRequestCapFromBody("Rate limit: 20 rpm, current usage: 4 rpm"), {
+ requests: 20,
+ windowMs: 60_000,
+ });
+ assert.deepEqual(parseRequestCapFromBody("quota: 1000 requests per hour"), {
+ requests: 1000,
+ windowMs: 3_600_000,
+ });
+});
+
+test("parseRequestCapFromBody ignores bodies without a request cap", () => {
+ assert.equal(parseRequestCapFromBody("Rate limit exceeded. Please retry after 20s."), null);
+ assert.equal(parseRequestCapFromBody(""), null);
+ assert.equal(parseRequestCapFromBody(null), null);
+ assert.equal(parseRequestCapFromBody({ error: { message: "overloaded" } }), null);
+ assert.equal(parseRequestCapFromBody("Maximum 0 requests within 1 minutes"), null);
+ assert.equal(parseRequestCapFromBody("processed 5 requests in 3 days"), null);
+ // usage statements are not ceilings
+ assert.equal(parseRequestCapFromBody("You made 120 requests in 1 minute; the limit is 60"), null);
+ assert.equal(parseRequestCapFromBody("Your 3 requests in 10 seconds exceeded the plan"), null);
+ assert.equal(
+ parseRequestCapFromBody("Rate limit exceeded: you sent 120 requests in 1 minute"),
+ null
+ );
+ assert.equal(parseRequestCapFromBody("Generate: 7 requests per minute"), null);
+ // the rpm shorthand needs a cap word before the figure too, or a low usage
+ // figure pins the connection; a cap word after it is deliberately not enough
+ assert.equal(parseRequestCapFromBody("Current usage: 4 rpm"), null);
+ assert.equal(parseRequestCapFromBody("Rate limit hit: you have made 3 rpm"), null);
+ assert.equal(parseRequestCapFromBody("Throttled: 20 RPM exceeded"), null);
+});
+
+test("a 429 with a request cap paces the limiter and is learned", async () => {
+ const connectionId = "tokenrouter-cap-conn";
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+
+ // The pipeline records headers first (which evicts the limiter on a 429)
+ // and then the body.
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, "glm-free");
+ rateLimitManager.updateFromResponseBody(
+ "tokenrouter",
+ connectionId,
+ TOKENROUTER_429,
+ 429,
+ "glm-free"
+ );
+
+ const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.ok(learned, "cap should be recorded as a learned limit");
+ assert.equal(learned.capRequests, 5);
+ assert.equal(learned.capWindowMs, 60_000);
+ assert.equal(learned.minTime, 12_000);
+ assert.equal(learned.limit, 5);
+
+ const fresh = captured.at(-1)!;
+ const capUpdate = fresh.updates.find((u) => u.reservoirRefreshAmount === 5);
+ assert.ok(capUpdate, "the rebuilt limiter should receive the cap");
+ assert.equal(capUpdate.reservoir, 0);
+ assert.equal(capUpdate.reservoirRefreshInterval, 60_000);
+ assert.equal(capUpdate.minTime, 12_000);
+});
+
+test("a learned cap survives the limiter eviction on the next 429", async () => {
+ const connectionId = "tokenrouter-evict-conn";
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, "glm-free");
+ rateLimitManager.updateFromResponseBody(
+ "tokenrouter",
+ connectionId,
+ TOKENROUTER_429,
+ 429,
+ "glm-free"
+ );
+ // A second 429 (say from an in-flight request) whose body says nothing useful.
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, "glm-free");
+ rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, "{}", 429, "glm-free");
+
+ const before = captured.length;
+ await rateLimitManager.withRateLimit("tokenrouter", connectionId, "glm-free", async () => "ok");
+ assert.equal(captured.length, before + 1, "the next request builds a fresh limiter");
+
+ const rebuilt = captured.at(-1)!.options;
+ assert.equal(rebuilt.reservoir, 5);
+ assert.equal(rebuilt.reservoirRefreshAmount, 5);
+ assert.equal(rebuilt.reservoirRefreshInterval, 60_000);
+ assert.equal(rebuilt.minTime, 12_000);
+});
+
+test("a learned cap is restored from persistence after a restart", async () => {
+ const connectionId = "tokenrouter-restart-conn";
+ captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+ await rateLimitManager.__flushLearnedLimitsForTests();
+
+ await rateLimitManager.__resetRateLimitManagerForTests();
+ await rateLimitManager.initializeRateLimits();
+
+ const restored = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.ok(restored, "cap should be reloaded from settings");
+ assert.equal(restored.capRequests, 5);
+ assert.equal(restored.capWindowMs, 60_000);
+
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+ const options = captured.at(-1)!.options;
+ assert.equal(options.reservoir, 5);
+ assert.equal(options.reservoirRefreshInterval, 60_000);
+ assert.equal(options.minTime, 12_000);
+});
+
+test("TokenRouter capacity 503 bodies classify as model capacity, not a provider outage", () => {
+ for (const text of ["503 system disk overloaded", "system cpu overloaded"]) {
+ assert.equal(classifyErrorText(text), RateLimitReason.MODEL_CAPACITY, text);
+ const rule = findMatchingErrorRule(503, text);
+ assert.equal(rule?.reason, "model_capacity", text);
+ assert.equal(rule?.backoff, true, text);
+ }
+});
+
+test("a cap never paces closer than the operator minTime floor", async () => {
+ const connectionId = "tokenrouter-floor-conn";
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ await rateLimitManager.applyRequestQueueSettings({
+ ...DEFAULT_RESILIENCE_SETTINGS.requestQueue,
+ minTimeBetweenRequestsMs: 200,
+ });
+ try {
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody(
+ "tokenrouter",
+ connectionId,
+ "Rate limit exceeded: 1000 requests per minute",
+ 429,
+ null
+ );
+
+ const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.equal(learned.capRequests, 1000);
+ assert.equal(learned.minTime, 200, "window/N = 60ms is below the 200ms floor");
+ const capUpdate = captured.at(-1)!.updates.find((u) => u.reservoirRefreshAmount === 1000);
+ assert.equal(capUpdate?.minTime, 200);
+ } finally {
+ await rateLimitManager.applyRequestQueueSettings(DEFAULT_RESILIENCE_SETTINGS.requestQueue);
+ }
+});
+
+test("an explicit RPM override outranks a learned cap", async () => {
+ const connectionId = "tokenrouter-override-conn";
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.refreshConnectionRateLimits(connectionId, { rpm: 100 });
+
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+
+ const rebuilt = captured.at(-1)!.options;
+ assert.equal(rebuilt.reservoir, 100, "the operator's rpm override wins at construction");
+ assert.equal(rebuilt.reservoirRefreshInterval, 60_000);
+
+ // and at runtime: the body path must not pace the live limiter with the cap
+ const bodyPathLimiter = captured.find((c) =>
+ c.updates.some((u) => u.reservoir === 0 && u.reservoirRefreshAmount === undefined)
+ );
+ assert.ok(bodyPathLimiter, "body path only spends the window under an rpm override");
+ assert.ok(
+ captured.every((c) => !c.updates.some((u) => u.reservoirRefreshAmount === 5)),
+ "no live update applied the 5-per-minute cap"
+ );
+ const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.equal(learned.capRequests, 5, "cap still recorded for when the override goes");
+});
+
+test("a header-learned update keeps the body-learned cap", async () => {
+ const connectionId = "tokenrouter-merge-conn";
+ captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+ rateLimitManager.updateFromHeaders(
+ "tokenrouter",
+ connectionId,
+ { [STANDARD_HEADERS.limit]: "100", [STANDARD_HEADERS.remaining]: "90" },
+ 200,
+ null
+ );
+
+ const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.equal(learned.limit, 100, "header value recorded");
+ assert.equal(learned.capRequests, 5, "cap not dropped by the header update");
+ assert.equal(learned.capWindowMs, 60_000);
+});
+
+test("the body path rebuilds the limiter even when the header hook did not run", async () => {
+ const connectionId = "tokenrouter-body-only-conn";
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "warm");
+ const before = captured.length;
+
+ rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+
+ assert.equal(captured.length, before + 1, "a fresh limiter carries the cap in its options");
+ const rebuilt = captured.at(-1)!;
+ assert.equal(rebuilt.options.reservoir, 5);
+ assert.equal(rebuilt.options.reservoirRefreshInterval, 60_000);
+ assert.ok(
+ rebuilt.updates.some((u) => u.reservoir === 0),
+ "and starts with an empty reservoir"
+ );
+});
+
+test("an operator refresh of a connection forgets its learned cap", async () => {
+ const connectionId = "tokenrouter-refresh-conn";
+ const captured = captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody("tokenrouter", connectionId, TOKENROUTER_429, 429, null);
+ assert.equal(rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`].capRequests, 5);
+
+ rateLimitManager.refreshConnectionRateLimits(connectionId, { minTime: 50 });
+
+ const learned = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.equal(learned.capRequests, undefined, "cap cleared");
+ assert.equal(learned.provider, "tokenrouter", "entry itself kept");
+ await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+ assert.notEqual(captured.at(-1)!.options.reservoir, 5, "rebuilt limiter is uncapped");
+
+ // the clear reaches persistence too
+ await rateLimitManager.__flushLearnedLimitsForTests();
+ await rateLimitManager.__resetRateLimitManagerForTests();
+ await rateLimitManager.initializeRateLimits();
+ const restored = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.equal(restored?.capRequests, undefined, "cap does not come back after a restart");
+});
+
+test("a cap that cannot be honoured within the queue budget is not learned", async () => {
+ const connectionId = "tokenrouter-huge-window-conn";
+ captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody(
+ "tokenrouter",
+ connectionId,
+ "Quota exceeded: limit of 1 requests per 24 hours",
+ 429,
+ null
+ );
+
+ assert.equal(rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`], undefined);
+});
+
+test("a persisted cap that no longer fits the queue budget is not re-applied", async () => {
+ // A real connection row, so the restart below auto-enables it and builds its
+ // limiter before the persisted limits load, the way boot does.
+ const connection = await providersDb.createProviderConnection({
+ provider: "tokenrouter",
+ authType: "apikey",
+ name: "budget-shrank",
+ apiKey: "sk-budget-shrank",
+ isActive: true,
+ });
+ const connectionId = connection.id;
+ try {
+ captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ // Learned while the operator allowed requests to wait ten minutes.
+ rateLimitManager.refreshConnectionRateLimits(connectionId, { maxWaitMs: 600_000 });
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody(
+ "tokenrouter",
+ connectionId,
+ "Quota exceeded: limit of 1 requests per 5 minutes",
+ 429,
+ null
+ );
+ assert.equal(
+ rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`].minTime,
+ 300_000
+ );
+ await rateLimitManager.__flushLearnedLimitsForTests();
+
+ // Restart without the override: the budget is back to the 90s default.
+ await rateLimitManager.__resetRateLimitManagerForTests();
+ const captured = captureLimiters();
+ await rateLimitManager.initializeRateLimits();
+
+ const restored = rateLimitManager.getLearnedLimits()[`tokenrouter:${connectionId}`];
+ assert.equal(restored?.capRequests, 1, "the cap itself is still remembered");
+ const booted = captured.at(-1)!;
+ assert.ok(booted, "boot builds the auto-enabled connection's limiter");
+ assert.ok(
+ booted.updates.every((u) => u.reservoirRefreshAmount !== 1 && u.minTime !== 300_000),
+ "the restore path must not pace the live limiter with the oversized cap"
+ );
+
+ const before = captured.length;
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ await rateLimitManager.withRateLimit("tokenrouter", connectionId, null, async () => "ok");
+ assert.equal(captured.length, before + 1, "the 429 rebuilds the limiter");
+ const rebuilt = captured.at(-1)!.options;
+ assert.notEqual(rebuilt.reservoir, 1, "the rebuild path must not carry the oversized cap");
+ assert.notEqual(rebuilt.minTime, 300_000);
+ } finally {
+ await providersDb.deleteProviderConnection(connectionId);
+ }
+});
+
+test("a persisted cap that fits the queue budget is applied to the boot-time limiter", async () => {
+ const connection = await providersDb.createProviderConnection({
+ provider: "tokenrouter",
+ authType: "apikey",
+ name: "budget-fits",
+ apiKey: "sk-budget-fits",
+ isActive: true,
+ });
+ const connectionId = connection.id;
+ try {
+ captureLimiters();
+ rateLimitManager.enableRateLimitProtection(connectionId);
+ rateLimitManager.updateFromHeaders("tokenrouter", connectionId, {}, 429, null);
+ rateLimitManager.updateFromResponseBody(
+ "tokenrouter",
+ connectionId,
+ TOKENROUTER_429,
+ 429,
+ null
+ );
+ await rateLimitManager.__flushLearnedLimitsForTests();
+
+ await rateLimitManager.__resetRateLimitManagerForTests();
+ const captured = captureLimiters();
+ await rateLimitManager.initializeRateLimits();
+
+ const booted = captured.at(-1)!;
+ assert.equal(booted.options.id, `tokenrouter:${connectionId}`);
+ assert.ok(
+ booted.updates.some((u) => u.reservoirRefreshAmount === 5 && u.minTime === 12_000),
+ "the restore path paces the boot-time limiter with the persisted cap"
+ );
+ } finally {
+ await providersDb.deleteProviderConnection(connectionId);
+ }
+});
+
+test("isValidRequestCap bounds what the restore path accepts", () => {
+ const { isValidRequestCap } = requestCapModule;
+ assert.equal(isValidRequestCap({ requests: 5, windowMs: 60_000 }), true);
+ assert.equal(isValidRequestCap({ requests: 0, windowMs: 60_000 }), false);
+ assert.equal(isValidRequestCap({ requests: 2.5, windowMs: 60_000 }), false);
+ assert.equal(isValidRequestCap({ requests: 5, windowMs: 1e12 }), false);
+ assert.equal(isValidRequestCap({ requests: 5, windowMs: 10 }), false);
+ assert.equal(isValidRequestCap({ requests: 5, windowMs: Number.NaN }), false);
+});
diff --git a/tests/unit/resolve-omniroute-base-url.test.ts b/tests/unit/resolve-omniroute-base-url.test.ts
index 3e629fe1e6..a8a76e9889 100644
--- a/tests/unit/resolve-omniroute-base-url.test.ts
+++ b/tests/unit/resolve-omniroute-base-url.test.ts
@@ -50,3 +50,7 @@ test("resolveOmniRouteBaseUrl ignores blank values", () => {
test("resolveOmniRouteBaseUrl uses the default localhost fallback", () => {
assert.equal(resolveOmniRouteBaseUrl({}), DEFAULT_OMNIROUTE_BASE_URL);
});
+
+test("resolveOmniRouteBaseUrl uses custom port when PORT env is set", () => {
+ assert.equal(resolveOmniRouteBaseUrl({ PORT: 37128 }), "http://localhost:37128");
+});
diff --git a/tests/unit/resource-pressure-gate-recovery.test.ts b/tests/unit/resource-pressure-gate-recovery.test.ts
new file mode 100644
index 0000000000..564903f5df
--- /dev/null
+++ b/tests/unit/resource-pressure-gate-recovery.test.ts
@@ -0,0 +1,132 @@
+// Regression for https://github.com/diegosouzapw/OmniRoute/issues/13821.
+//
+// The structural admission gate (chatBodyAdmission.ts, admitChatRequest) is
+// the FIRST caller in the request path to consult pressure severity, ahead of
+// every other code path that would otherwise call checkResourcePressureGuard()
+// (handleChatCore, checkResourcePressureBeforeProviderWork,
+// AdaptiveAdmissionRuntimeImpl.acquire). In production, one of those other
+// paths is what first observes a real critical condition (a request that
+// slips past the gate before it starts shedding, or the AdaptiveAdmission
+// runtime for a different combo route) and flips the singleton's cached
+// `state.severity` to "critical" via the sustained-sample tracker. From that
+// point on, the structural gate sheds every subsequent request before any of
+// those downstream paths can run again — so `check()` never gets called
+// again and the guard can never observe recovery, even after the real
+// condition clears. Only a full process restart clears it.
+//
+// This test drives the singleton to "critical" through the SAME sustained
+// sample-and-recover path production uses (classifyAdaptiveResourcePressure's
+// v8_heap_ratio + a two-sample streak, not the synchronous immediate-heap
+// escape hatch), using an injected mock clock so no scheduled refresh from
+// the setup phase can resolve on its own and contaminate the assertion. Only
+// the exact calls under test (`defaultPressureSeverity`, twice) are allowed
+// to drive anything after the singleton is latched critical.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { defaultPressureSeverity } =
+ await import("../../src/shared/middleware/chatBodyAdmission.ts");
+const { reloadResourcePressureRuntime, checkResourcePressureGuard } =
+ await import("../../open-sse/utils/resourcePressure.ts");
+
+const MiB = 1024 * 1024;
+
+function signals(observedAtMs: number, heapUsedMb: number) {
+ return {
+ observedAtMs,
+ v8: { heapUsedBytes: heapUsedMb * MiB, heapLimitBytes: 1000 * MiB },
+ process: {
+ rssBytes: 0,
+ externalBytes: 0,
+ arrayBuffersBytes: 0,
+ availableBytes: null,
+ constrainedBytes: null,
+ },
+ cgroup: { currentBytes: null, maxBytes: null, highBytes: null, fileBytes: null, events: null },
+ psi: null,
+ };
+}
+
+/** Advances the mock clock and drives one refresh cycle to completion. */
+async function tick(runtime: { whenRefreshSettled: () => Promise }): Promise {
+ checkResourcePressureGuard();
+ await runtime.whenRefreshSettled();
+}
+
+test("defaultPressureSeverity recovers to normal once the underlying pressure clears, driven only by repeated calls to itself", async () => {
+ let underPressure = true;
+ let clockMs = 0;
+ const staleAfterMs = 1_000;
+ const runtime = reloadResourcePressureRuntime({
+ heapThresholdMb: null,
+ immediateHeapUsedMb: () => 0, // never trip the synchronous escape hatch — force the sample path
+ nowMs: () => clockMs,
+ sample: async () => signals(clockMs, underPressure ? 950 : 100), // 950/1000 = 0.95 >= criticalRatio(0.92)
+ staleAfterMs,
+ });
+
+ try {
+ // Drive two sustained critical samples (default sustainedSamplesCritical
+ // is 2) via checkResourcePressureGuard directly, NOT defaultPressureSeverity —
+ // this mirrors some other request's handleChatCore call being the thing
+ // that first observes the real condition in production, not the gate
+ // itself. Each call is followed by advancing the clock past staleAfterMs
+ // so the NEXT call is the one that schedules and awaits the next sample.
+ await tick(runtime);
+ clockMs += staleAfterMs + 1;
+ await tick(runtime);
+
+ // The seed is fully settled now; nextRefreshAtMs is in the past relative
+ // to the current clock only once we advance it again below — right now
+ // there is nothing scheduled, so nothing can resolve on its own.
+ assert.equal(defaultPressureSeverity(), "critical");
+
+ // The underlying condition clears and enough time passes for the next
+ // sample to be due. From here on nothing but defaultPressureSeverity's
+ // own two calls touches the singleton.
+ underPressure = false;
+ clockMs += staleAfterMs + 1;
+
+ // This call's synchronous return still reflects the pre-refresh cached
+ // decision (matches production: check() answers instantly, the resample
+ // it schedules resolves in the background) — both the old passive read
+ // and the fixed active read must still say "critical" here.
+ assert.equal(defaultPressureSeverity(), "critical");
+
+ // Let whatever got scheduled by the call above resolve. Before the fix,
+ // defaultPressureSeverity never called check() at all, so nothing was
+ // scheduled here and this is a no-op — the singleton stays latched at
+ // "critical" forever. The fix must have scheduled and awaited a real
+ // resample from its own call above.
+ await runtime.whenRefreshSettled();
+ assert.equal(defaultPressureSeverity(), "normal");
+ } finally {
+ runtime.dispose();
+ }
+});
+
+test("defaultPressureSeverity still sheds while genuinely critical, across repeated calls", async () => {
+ let clockMs = 0;
+ const staleAfterMs = 1_000;
+ const runtime = reloadResourcePressureRuntime({
+ heapThresholdMb: null,
+ immediateHeapUsedMb: () => 0,
+ nowMs: () => clockMs,
+ sample: async () => signals(clockMs, 950),
+ staleAfterMs,
+ });
+
+ try {
+ await tick(runtime);
+ clockMs += staleAfterMs + 1;
+ await tick(runtime);
+
+ assert.equal(defaultPressureSeverity(), "critical");
+ clockMs += staleAfterMs + 1;
+ assert.equal(defaultPressureSeverity(), "critical");
+ await runtime.whenRefreshSettled();
+ assert.equal(defaultPressureSeverity(), "critical");
+ } finally {
+ runtime.dispose();
+ }
+});
diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts
index 925b9c3795..b790fdece3 100644
--- a/tests/unit/response-sanitizer.test.ts
+++ b/tests/unit/response-sanitizer.test.ts
@@ -285,6 +285,79 @@ test("sanitizeOpenAIResponse preserves OpenRouter native reasoning and signature
);
});
+test("sanitizeOpenAIResponse promotes reasoning_details text to reasoning_content even when reasoning is also present", () => {
+ // OpenRouter returns BOTH a `reasoning` string AND a `reasoning_details[]`
+ // array with the same thinking text for DeepSeek V4 / GLM 5.3 / Kimi K3.
+ // Clients (opencode) only read reasoning_content, so the details text must be
+ // mirrored into reasoning_content regardless of the `reasoning` alias being
+ // present (#12665).
+ const sanitized = sanitizeOpenAIResponse({
+ model: "openrouter/deepseek/deepseek-v4-flash",
+ choices: [
+ {
+ message: {
+ role: "assistant",
+ content: "Visible answer",
+ reasoning: "Hmm, let me think this through",
+ reasoning_details: [
+ { type: "reasoning.text", text: "Hmm, let me think this through" },
+ ],
+ },
+ },
+ ],
+ });
+
+ const message = (
+ sanitized as {
+ choices: Array<{
+ message: {
+ reasoning?: unknown;
+ reasoning_content?: unknown;
+ reasoning_details?: unknown;
+ };
+ }>;
+ }
+ ).choices[0].message;
+ assert.equal(message.reasoning, "Hmm, let me think this through");
+ assert.equal(message.reasoning_content, "Hmm, let me think this through");
+ assert.deepEqual(message.reasoning_details, [
+ { type: "reasoning.text", text: "Hmm, let me think this through" },
+ ]);
+});
+
+test("sanitizeOpenAIResponse does not flatten signature-only reasoning_details into reasoning_content", () => {
+ // Regression guard for the flip side: non-text details entries (encrypted
+ // signatures) must NOT be coerced into reasoning_content text (#12665).
+ const sanitized = sanitizeOpenAIResponse({
+ model: "openrouter/moonshotai/kimi-k3",
+ choices: [
+ {
+ message: {
+ role: "assistant",
+ content: "Visible answer",
+ reasoning: "native reasoning",
+ reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }],
+ },
+ },
+ ],
+ });
+
+ const message = (
+ sanitized as {
+ choices: Array<{
+ message: {
+ reasoning?: unknown;
+ reasoning_content?: unknown;
+ reasoning_details?: unknown;
+ };
+ }>;
+ }
+ ).choices[0].message;
+ assert.equal(message.reasoning_content, undefined);
+ assert.equal(message.reasoning, "native reasoning");
+ assert.deepEqual(message.reasoning_details, [{ type: "reasoning.encrypted", data: "sig" }]);
+});
+
test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => {
const sanitized = sanitizeOpenAIResponse({
model: "openrouter/model",
@@ -533,6 +606,39 @@ test("sanitizeStreamingChunk preserves client-readable reasoning deltas", () =>
assert.equal((sanitized as any).choices[0].delta.reasoning_content, undefined);
});
+test("sanitizeStreamingChunk promotes reasoning_details text when reasoning is also present in the delta", () => {
+ // Streaming parity for #12665: OpenRouter streams reasoning_details[].text
+ // chunks alongside a `reasoning` string; reasoning_content must still be
+ // populated for the client.
+ const sanitized = sanitizeStreamingChunk({
+ choices: [
+ {
+ delta: {
+ reasoning: "thinking chunk",
+ reasoning_details: [{ type: "reasoning.text", text: "thinking chunk" }],
+ },
+ },
+ ],
+ });
+
+ const delta = (
+ sanitized as {
+ choices: Array<{
+ delta: {
+ reasoning?: unknown;
+ reasoning_content?: unknown;
+ reasoning_details?: unknown;
+ };
+ }>;
+ }
+ ).choices[0].delta;
+ assert.equal(delta.reasoning, "thinking chunk");
+ assert.equal(delta.reasoning_content, "thinking chunk");
+ assert.deepEqual(delta.reasoning_details, [
+ { type: "reasoning.text", text: "thinking chunk" },
+ ]);
+});
+
test("sanitizeStreamingChunk preserves and mirrors Copilot reasoning_text deltas", () => {
const sanitized = sanitizeStreamingChunk({
choices: [
diff --git a/tests/unit/responses-active-stream-custom-tool.test.ts b/tests/unit/responses-active-stream-custom-tool.test.ts
index f1c78fa4cf..c3a40ac1db 100644
--- a/tests/unit/responses-active-stream-custom-tool.test.ts
+++ b/tests/unit/responses-active-stream-custom-tool.test.ts
@@ -52,6 +52,8 @@ test("active Responses stream restores declared custom tool metadata", async ()
null,
false,
false,
+ // #12905 inserted `requestedThinking` as the 14th positional; customToolNames is 15th.
+ undefined,
new Set(["exec"])
);
diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts
index b3ae941a02..cc061b6fc5 100644
--- a/tests/unit/responses-transformer.test.ts
+++ b/tests/unit/responses-transformer.test.ts
@@ -72,9 +72,22 @@ test("createResponsesApiTransformStream converts plain chat deltas into Response
);
assert.ok(types.includes("response.created"));
assert.ok(types.includes("response.in_progress"));
+
+ const inProgress = JSON.parse(
+ events.find((event) => event.event === "response.in_progress").data
+ ).response;
+ assert.ok(Array.isArray(inProgress.output), "response.in_progress must include an output array");
+ assert.deepEqual(inProgress.output, []);
+
assert.ok(types.includes("response.output_item.added"));
+ const addedItem = JSON.parse(
+ events.find((event) => event.event === "response.output_item.added").data
+ ).item;
+ assert.equal(addedItem.status, "in_progress");
+
assert.ok(types.includes("response.output_text.done"));
assert.equal(completed.output[0].content[0].text, "Hello");
+ assert.equal(completed.output[0].status, "completed");
assert.deepEqual(completed.usage, {
input_tokens: 1,
input_tokens_details: { cached_tokens: 0 },
diff --git a/tests/unit/responses-usage-trailing-6906.test.ts b/tests/unit/responses-usage-trailing-6906.test.ts
index 65f403cf9b..8027c96968 100644
--- a/tests/unit/responses-usage-trailing-6906.test.ts
+++ b/tests/unit/responses-usage-trailing-6906.test.ts
@@ -46,7 +46,13 @@ test("BUG #6906: live translator — response.completed carries usage when the u
assert.ok(completedEvent, "response.completed event should be emitted");
assert.deepEqual(
completedEvent.data.response.usage,
- { input_tokens: 2249, output_tokens: 123, total_tokens: 2372 },
+ {
+ input_tokens: 2249,
+ input_tokens_details: { cached_tokens: 0 },
+ output_tokens: 123,
+ output_tokens_details: { reasoning_tokens: 0 },
+ total_tokens: 2372,
+ },
"response.completed must carry usage even when the usage-only chunk trails finish_reason"
);
});
diff --git a/tests/unit/security/live-server-allowlist.test.ts b/tests/unit/security/live-server-allowlist.test.ts
index fbd4fd9be8..041c784797 100644
--- a/tests/unit/security/live-server-allowlist.test.ts
+++ b/tests/unit/security/live-server-allowlist.test.ts
@@ -66,6 +66,18 @@ describe("buildAllowedOrigins", () => {
// Defaults remain.
assert.equal(out.has("http://localhost:20128"), true);
});
+
+ it("includes dynamic loopback origins when custom PORT is configured", () => {
+ const env = {
+ ...EMPTY_ENV,
+ PORT: "37128",
+ };
+ const out = buildAllowedOrigins(env);
+ assert.equal(out.has("http://localhost:37128"), true);
+ assert.equal(out.has("http://127.0.0.1:37128"), true);
+ assert.equal(out.has("http://[::1]:37128"), true);
+ assert.equal(out.has("http://localhost:20128"), true);
+ });
});
describe("buildAllowedHosts", () => {
@@ -150,6 +162,11 @@ describe("isOriginAllowed", () => {
assert.equal(isOriginAllowed("http://100.96.135.160:20128", env), true);
});
+ it("does not treat a wildcard host as an allow-all origin policy", () => {
+ const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "*" };
+ assert.equal(isOriginAllowed("http://100.90.139.116:37128", env), false);
+ });
+
it("does NOT accept a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is unset", () => {
// Critical security invariant: without explicit opt-in, the LAN/Tailscale
// surface is closed even though the listener is reachable.
diff --git a/tests/unit/sse-parser.test.ts b/tests/unit/sse-parser.test.ts
index 5c8bfe627a..b062e1baed 100644
--- a/tests/unit/sse-parser.test.ts
+++ b/tests/unit/sse-parser.test.ts
@@ -431,3 +431,60 @@ test("parseSSEToGeminiResponse ignores thought/thoughtSignature parts", () => {
assert.ok(parsed);
assert.equal(parsed.choices[0].message.content, "visible answer");
});
+
+test("parseSSEToGeminiResponse preserves text that carries a thoughtSignature", () => {
+ const rawSSE = [
+ `data: ${JSON.stringify({
+ response: {
+ candidates: [
+ {
+ content: {
+ parts: [
+ { text: "internal reasoning", thought: true },
+ { text: "visible answer after thinking", thoughtSignature: "sig-xyz-123" },
+ ],
+ },
+ finishReason: "STOP",
+ },
+ ],
+ },
+ })}`,
+ ].join("\n");
+
+ const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.8-flash-tiered");
+
+ assert.ok(parsed);
+ assert.equal(parsed.choices[0].message.content, "visible answer after thinking");
+});
+
+test("parseSSEToGeminiResponse extracts native functionCall parts carrying thoughtSignature", () => {
+ const rawSSE = [
+ `data: ${JSON.stringify({
+ response: {
+ candidates: [
+ {
+ content: {
+ parts: [
+ {
+ functionCall: { name: "search_documentation", args: { query: "test" } },
+ thoughtSignature: "sig-abc",
+ },
+ ],
+ },
+ finishReason: "STOP",
+ },
+ ],
+ },
+ })}`,
+ ].join("\n");
+
+ const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.8-flash-tiered");
+
+ assert.ok(parsed);
+ assert.equal(parsed.choices[0].finish_reason, "tool_calls");
+ assert.equal(parsed.choices[0].message.tool_calls?.length, 1);
+ assert.equal(parsed.choices[0].message.tool_calls[0].function.name, "search_documentation");
+ assert.deepEqual(JSON.parse(parsed.choices[0].message.tool_calls[0].function.arguments), {
+ query: "test",
+ });
+});
diff --git a/tests/unit/sse-stream-buffer-bytes.test.ts b/tests/unit/sse-stream-buffer-bytes.test.ts
index be54b24f86..8352263edb 100644
--- a/tests/unit/sse-stream-buffer-bytes.test.ts
+++ b/tests/unit/sse-stream-buffer-bytes.test.ts
@@ -49,11 +49,12 @@ test.describe("SSE stream buffer budget", () => {
assert.equal(writableBudget(transform), 65536);
});
- // The defect this pins: glm.ts has passed a 16th positional argument since
- // #12179, and the signature stopped at 15. It was a type error, and the value
- // was dropped — the 64 KB that call site asks for never reached the queue.
- // These are the exact 16 arguments glm.ts passes.
- test("the convenience wrapper carries a 16th positional budget through", () => {
+ // The defect this pins: glm.ts passes its buffer budget as the LAST positional
+ // argument, and the signature once stopped one short — a type error, and the
+ // value was dropped, so the 64 KB that call site asks for never reached the
+ // queue. The budget is now the 17th positional (requestToolIdentityMap sits at
+ // 16, #8151); these are the exact arguments open-sse/executors/glm.ts passes.
+ test("the convenience wrapper carries the trailing positional budget through", () => {
const transform = createSSETransformStreamWithLogger(
FORMATS.CLAUDE,
FORMATS.OPENAI,
@@ -70,6 +71,7 @@ test.describe("SSE stream buffer budget", () => {
false,
undefined,
undefined,
+ undefined,
65536
);
diff --git a/tests/unit/token-expiry-numeric-epoch.test.ts b/tests/unit/token-expiry-numeric-epoch.test.ts
new file mode 100644
index 0000000000..1ece5f30ca
--- /dev/null
+++ b/tests/unit/token-expiry-numeric-epoch.test.ts
@@ -0,0 +1,60 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+
+// We import the exported helper directly. The module auto-starts the
+// health-check timer on import, so we stop it immediately.
+import { parseTokenExpiryMs, stopTokenHealthCheck } from "../../src/lib/tokenHealthCheck.ts";
+
+stopTokenHealthCheck();
+
+/**
+ * Regression guard: `provider_connections.expires_at` is a TEXT column, so an
+ * epoch timestamp always reads back as a string. `new Date("1789012345678")`
+ * is an Invalid Date, and an epoch-seconds *number* parsed as milliseconds
+ * lands in 1970 — both break the expiry-driven refresh in checkConnection():
+ *
+ * - NaN -> getEffectiveTokenExpiryMs() returns 0 -> hasKnownExpiry false.
+ * For a ROTATING_REFRESH_PROVIDERS entry (codex, claude, kiro, openai, …)
+ * shouldRefreshByInterval is false too, so the connection is never
+ * refreshed at all.
+ * - 1970 -> isAboutToExpire is permanently true, so every sweep refreshes
+ * the connection, burning refresh-token rotations.
+ *
+ * The sibling Copilot helper already handled both shapes; this asserts the
+ * single shared parser does the same for every connection.
+ */
+describe("parseTokenExpiryMs", () => {
+ const MS = Date.parse("2026-09-12T12:00:00.000Z");
+ const SECONDS = Math.floor(MS / 1000);
+
+ it("parses epoch milliseconds as a number", () => {
+ assert.equal(parseTokenExpiryMs(MS), MS);
+ });
+
+ it("parses epoch seconds as a number", () => {
+ assert.equal(parseTokenExpiryMs(SECONDS), SECONDS * 1000);
+ });
+
+ it("parses epoch milliseconds given as a string", () => {
+ assert.equal(parseTokenExpiryMs(String(MS)), MS);
+ });
+
+ it("parses epoch seconds given as a string", () => {
+ assert.equal(parseTokenExpiryMs(String(SECONDS)), SECONDS * 1000);
+ });
+
+ it("parses an ISO 8601 string", () => {
+ assert.equal(parseTokenExpiryMs("2026-09-12T12:00:00.000Z"), MS);
+ });
+
+ it("returns 0 for values that carry no usable time", () => {
+ assert.equal(parseTokenExpiryMs(null), 0);
+ assert.equal(parseTokenExpiryMs(undefined), 0);
+ assert.equal(parseTokenExpiryMs(""), 0);
+ assert.equal(parseTokenExpiryMs(" "), 0);
+ assert.equal(parseTokenExpiryMs("not-a-date"), 0);
+ assert.equal(parseTokenExpiryMs(0), 0);
+ assert.equal(parseTokenExpiryMs(Number.NaN), 0);
+ assert.equal(parseTokenExpiryMs({}), 0);
+ });
+});
diff --git a/tests/unit/token-refresh-race-comprehensive.test.ts b/tests/unit/token-refresh-race-comprehensive.test.ts
index 070cd85601..37df7ba9f4 100644
--- a/tests/unit/token-refresh-race-comprehensive.test.ts
+++ b/tests/unit/token-refresh-race-comprehensive.test.ts
@@ -176,3 +176,27 @@ test("Imports: base.ts imports runWithOnPersist from open-sse tokenRefresh", asy
assert.match(src, /runWithOnPersist/);
assert.match(src, /from\s+"\.\.\/services\/tokenRefresh\.ts"/);
});
+
+
+test("serialized refresh re-checks rotation inside the lane, not before waiting", async () => {
+ const src = await read("open-sse/services/tokenRefresh.ts");
+ const start = src.indexOf("async function _getAccessTokenWithStalenessCheck");
+ const inner = src.indexOf("async function _refreshWithFreshCredentials");
+ assert.ok(start >= 0 && inner > start, "staleness helper must wrap the freshness re-check");
+ const wrapper = src.slice(start, inner);
+ assert.match(
+ wrapper,
+ /serializeRefresh\(provider,\s*\(\)\s*=>/,
+ "the network POST must stay behind serializeRefresh"
+ );
+ assert.match(wrapper, /_refreshWithFreshCredentials/);
+ assert.doesNotMatch(
+ wrapper,
+ /lookupRotation/,
+ "lookupRotation before serializeRefresh is the race that burns a Claude refresh token"
+ );
+ const body = src.slice(inner, inner + 2500);
+ assert.match(body, /lookupRotation\(/);
+ assert.match(body, /recordRotation\(/);
+ assert.match(body, /_getAccessTokenInternal\(/);
+});
diff --git a/tests/unit/token-refresh-serialized-stale-rotation.test.ts b/tests/unit/token-refresh-serialized-stale-rotation.test.ts
new file mode 100644
index 0000000000..4c9f468a43
--- /dev/null
+++ b/tests/unit/token-refresh-serialized-stale-rotation.test.ts
@@ -0,0 +1,154 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts");
+const { __resetRefreshSerializerForTest } = await import("../../open-sse/services/refreshSerializer.ts");
+const { lookupRotation } = await import("../../open-sse/services/tokenRefresh/rotationMap.ts");
+
+const { getAccessToken } = tokenRefresh;
+
+type LogLevel = "debug" | "info" | "warn" | "error";
+type LogEntry = { level: LogLevel; message: unknown };
+
+function createLog() {
+ const entries: LogEntry[] = [];
+ const push = (level: LogLevel, args: unknown[]) => {
+ entries.push({ level, message: args[1] });
+ };
+ return {
+ entries,
+ debug: (...args: unknown[]) => push("debug", args),
+ info: (...args: unknown[]) => push("info", args),
+ warn: (...args: unknown[]) => push("warn", args),
+ error: (...args: unknown[]) => push("error", args),
+ };
+}
+
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+function bodyToString(body: BodyInit | null | undefined) {
+ if (typeof body === "string") return body;
+ if (body instanceof URLSearchParams) return body.toString();
+ return String(body ?? "");
+}
+
+function refreshTokenFromBody(body: BodyInit | null | undefined) {
+ return new URLSearchParams(bodyToString(body)).get("refresh_token");
+}
+
+async function withMockedFetch(fetchImpl: typeof fetch, fn: () => Promise) {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = fetchImpl;
+ try {
+ return await fn();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+}
+
+function resetRefreshState() {
+ tokenRefresh._clearTokenRotationMap();
+ __resetRefreshSerializerForTest();
+}
+
+test.beforeEach(() => {
+ resetRefreshState();
+});
+
+test("getAccessToken_Layer1QueuedBehindLayer2_DoesNotPostConsumedClaudeRefreshToken", async () => {
+ const previousSpacing = process.env.CODEX_REFRESH_SPACING_MS;
+ process.env.CODEX_REFRESH_SPACING_MS = "0";
+ const log = createLog();
+ const presented: string[] = [];
+ let firstPostEntered = false;
+ let releaseFirstPost!: () => void;
+ const holdFirstPost = new Promise((resolve) => {
+ releaseFirstPost = resolve;
+ });
+
+ try {
+ await withMockedFetch(async (_url, options = {}) => {
+ const presentedToken = refreshTokenFromBody(options.body);
+ presented.push(presentedToken || "");
+ if (presentedToken === "old-rt" && !firstPostEntered) {
+ firstPostEntered = true;
+ await holdFirstPost;
+ return jsonResponse({
+ access_token: "new-access",
+ refresh_token: "new-rt",
+ expires_in: 28800,
+ });
+ }
+ if (presentedToken === "old-rt") {
+ return jsonResponse({ error: "invalid_grant", error_description: "refresh_token_reused" }, 400);
+ }
+ throw new Error(`unexpected refresh_token ${presentedToken}`);
+ }, async () => {
+ const layer2 = getAccessToken("claude", { refreshToken: "old-rt" }, log);
+ await new Promise((resolve, reject) => {
+ const started = Date.now();
+ const tick = () => {
+ if (firstPostEntered) {
+ resolve();
+ return;
+ }
+ if (Date.now() - started > 2000) {
+ reject(new Error("Layer 2 never reached the Anthropic token endpoint"));
+ return;
+ }
+ setTimeout(tick, 5);
+ };
+ tick();
+ });
+
+ const layer1 = getAccessToken(
+ "claude",
+ { connectionId: "healthcheck-conn", refreshToken: "old-rt" },
+ log
+ );
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ releaseFirstPost();
+
+ const [layer2Result, layer1Result] = await Promise.all([layer2, layer1]);
+
+ assert.deepEqual(presented, ["old-rt"], "the consumed refresh token must be POSTed once");
+ assert.equal(layer2Result?.accessToken, "new-access");
+ assert.equal(layer2Result?.refreshToken, "new-rt");
+ assert.equal(layer1Result?.accessToken, "new-access");
+ assert.equal(layer1Result?.refreshToken, "new-rt");
+ assert.notEqual(
+ (layer1Result as { error?: string } | null)?.error,
+ "unrecoverable_refresh_error",
+ "Layer 1 must reuse the rotated tokens instead of burning the family"
+ );
+ });
+ } finally {
+ if (previousSpacing === undefined) delete process.env.CODEX_REFRESH_SPACING_MS;
+ else process.env.CODEX_REFRESH_SPACING_MS = previousSpacing;
+ resetRefreshState();
+ }
+});
+
+test("getAccessToken_Layer2Refresh_RecordsRotationForTheConsumedToken", async () => {
+ const log = createLog();
+
+ await withMockedFetch(async () => {
+ return jsonResponse({
+ access_token: "layer2-access",
+ refresh_token: "layer2-new-rt",
+ expires_in: 28800,
+ });
+ }, async () => {
+ const result = await getAccessToken("claude", { refreshToken: "layer2-old-rt" }, log);
+ assert.equal(result?.refreshToken, "layer2-new-rt");
+ const cached = lookupRotation("claude", "layer2-old-rt");
+ assert.ok(cached, "Layer 2 must record the rotation so a later stale caller can skip upstream");
+ assert.equal(cached.result.refreshToken, "layer2-new-rt");
+ assert.equal(cached.result.accessToken, "layer2-access");
+ });
+});
diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts
index aa675efb2b..ffdf97d9f8 100644
--- a/tests/unit/token-refresh-service.test.ts
+++ b/tests/unit/token-refresh-service.test.ts
@@ -962,9 +962,10 @@ test("getAccessToken cleans the in-flight cache after resolve and separates diff
log
);
- assert.equal(fetchCount, 3);
+ assert.equal(fetchCount, 2, "same consumed refresh token is served from the rotation map");
assert.equal(first.accessToken, "access-refresh-a");
assert.equal(second.accessToken, "access-refresh-a");
+ assert.equal(second.refreshToken, "next-refresh-a");
assert.equal(third.accessToken, "access-refresh-b");
}
);
diff --git a/tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts b/tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts
new file mode 100644
index 0000000000..3547d6894b
--- /dev/null
+++ b/tests/unit/tokenHealthCheck-unrecoverable-reread.test.ts
@@ -0,0 +1,65 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+
+const root = path.resolve(import.meta.dirname, "../..");
+const src = await readFile(path.join(root, "src/lib/tokenHealthCheck.ts"), "utf8");
+
+function unrecoverableSlice() {
+ const idx = src.indexOf("if (isUnrecoverableRefreshError(result))");
+ assert.ok(idx >= 0, "unrecoverable refresh branch must exist");
+ return src.slice(idx, idx + 3500);
+}
+
+test("tokenHealthCheck_UnrecoverableRefresh_RereadsConnectionUncached", () => {
+ const slice = unrecoverableSlice();
+ assert.match(
+ slice,
+ /getProviderConnectionById\(/,
+ "a concurrent Layer 2 persist can land between the sweep snapshot and invalid_grant; the cached row still holds the consumed refresh token and would skip the changed-since-sweep guard"
+ );
+ assert.doesNotMatch(
+ slice,
+ /getCachedProviderConnectionById/,
+ "the 5s connection-by-id cache is how credentialsChangedSinceSweep missed the just-persisted rotation"
+ );
+});
+
+test("tokenHealthCheck_UnrecoverableRefresh_DoesNotNullClaudeRefreshToken", () => {
+ const slice = unrecoverableSlice();
+ assert.match(
+ slice,
+ /shouldNullRefreshTokenAfterUnrecoverable/,
+ "Claude rotating tokens must not be wiped on the first invalid_grant; the live access token plus the new refresh token in DB are still recoverable"
+ );
+ assert.doesNotMatch(
+ slice,
+ /\.\.\.\(isRotatingProvider\s*\?\s*\{\s*refreshToken:\s*null\s*\}\s*:\s*\{\}\)/,
+ "the blanket rotating-provider null is what turns a dual-refresh race into sticky no_refresh_token"
+ );
+});
+
+test("shouldNullRefreshTokenAfterUnrecoverable_Claude_IsFalse", async () => {
+ const { shouldNullRefreshTokenAfterUnrecoverable, stopTokenHealthCheck } =
+ await import("../../src/lib/tokenHealthCheck.ts");
+ stopTokenHealthCheck();
+ assert.equal(shouldNullRefreshTokenAfterUnrecoverable("claude"), false);
+ assert.equal(shouldNullRefreshTokenAfterUnrecoverable("Claude"), false);
+});
+
+test("shouldNullRefreshTokenAfterUnrecoverable_Codex_IsTrue", async () => {
+ const { shouldNullRefreshTokenAfterUnrecoverable, stopTokenHealthCheck } =
+ await import("../../src/lib/tokenHealthCheck.ts");
+ stopTokenHealthCheck();
+ assert.equal(shouldNullRefreshTokenAfterUnrecoverable("codex"), true);
+ assert.equal(shouldNullRefreshTokenAfterUnrecoverable("openai"), true);
+});
+
+test("shouldNullRefreshTokenAfterUnrecoverable_Google_IsFalse", async () => {
+ const { shouldNullRefreshTokenAfterUnrecoverable, stopTokenHealthCheck } =
+ await import("../../src/lib/tokenHealthCheck.ts");
+ stopTokenHealthCheck();
+ assert.equal(shouldNullRefreshTokenAfterUnrecoverable("gemini"), false);
+ assert.equal(shouldNullRefreshTokenAfterUnrecoverable("antigravity"), false);
+});
diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts
index cf44d1b798..7073e3b969 100644
--- a/tests/unit/translator-openai-to-gemini.test.ts
+++ b/tests/unit/translator-openai-to-gemini.test.ts
@@ -866,7 +866,11 @@ test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schem
assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/);
assert.equal(result.enabledCreditTypes, undefined);
assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM);
- assert.equal(result.request.systemInstruction.parts.length, 1, "systemInstruction must contain only ANTIGRAVITY_DEFAULT_SYSTEM (#9030)");
+ assert.equal(
+ result.request.systemInstruction.parts.length,
+ 1,
+ "systemInstruction must contain only ANTIGRAVITY_DEFAULT_SYSTEM (#9030)"
+ );
// #9030 — Client system content moved to first user message to avoid upstream 429s
assert.equal(result.request.contents[0].parts[0].text, "Project rules");
assert.equal(result.request.contents[0].parts[1].text, "Read a file");
@@ -1026,6 +1030,28 @@ test("OpenAI -> Antigravity Gemini path preserves thinkingConfig (only Claude is
assert.equal((result as any).request?.generationConfig.thinkingConfig.includeThoughts, true);
});
+test("OpenAI -> Antigravity Gemini thinking models omit maxOutputTokens when max_tokens is undefined", () => {
+ const result = openaiToAntigravityRequest(
+ "gemini-3.8-flash-tiered",
+ {
+ messages: [{ role: "user", content: "Hello" }],
+ },
+ false,
+ { projectId: "proj-gemini-thinking" } as unknown as Parameters<
+ typeof openaiToAntigravityRequest
+ >[3]
+ ) as Record;
+
+ const envelopeRequest = result.request as Record | undefined;
+ const genConfig = envelopeRequest?.generationConfig as Record | undefined;
+ assert.ok(genConfig?.thinkingConfig, "expected thinkingConfig to be set");
+ assert.equal(
+ genConfig.maxOutputTokens,
+ undefined,
+ "maxOutputTokens must be undefined when not requested"
+ );
+});
+
// Regression for #2480: when projectId is stored in providerSpecificData rather than at
// the top level of the credential record, the Antigravity Cloud Code envelope must still
// pick it up — otherwise the /v1beta path 422s with "Missing Google projectId".
@@ -1622,3 +1648,161 @@ test("OpenAI -> Gemini allows thinkingConfig for unknown model (no spec)", () =>
assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 5000);
assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true);
});
+
+test("OpenAI -> Gemini pairs tool calls and responses per turn without cross-turn ID collision mismatch", () => {
+ const result = openaiToCloudCodeGeminiRequest(
+ "gemini-3.8-flash-high",
+ {
+ messages: [
+ { role: "user", content: "read file" },
+ {
+ role: "assistant",
+ content: null,
+ tool_calls: [
+ {
+ id: "call_collision_123",
+ type: "function",
+ function: { name: "read_file", arguments: '{"path":"a.txt"}' },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ tool_call_id: "call_collision_123",
+ content: "file content from turn 1",
+ },
+ { role: "user", content: "now run terminal command" },
+ {
+ role: "assistant",
+ content: null,
+ tool_calls: [
+ {
+ id: "call_collision_123",
+ type: "function",
+ function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ tool_call_id: "call_collision_123",
+ content: "terminal output from turn 2",
+ },
+ { role: "user", content: "done" },
+ ],
+ },
+ false
+ ) as any;
+
+ // Verify Turn 1 functionCall and functionResponse
+ const turn1Model = result.contents.find((c: any) =>
+ c.parts?.some((p: any) => p.functionCall?.name === "read_file")
+ );
+ assert.ok(turn1Model, "Turn 1 model functionCall must be read_file");
+
+ const turn1User = result.contents.find((c: any) =>
+ c.parts?.some(
+ (p: any) =>
+ p.functionResponse?.response?.result === "file content from turn 1" ||
+ p.functionResponse?.name === "read_file"
+ )
+ );
+ assert.ok(turn1User, "Turn 1 user functionResponse must exist");
+ const turn1Resp = turn1User.parts.find((p: any) => p.functionResponse);
+ assert.equal(
+ turn1Resp.functionResponse.name,
+ "read_file",
+ "Turn 1 functionResponse name must match functionCall name, not be overwritten by turn 2"
+ );
+ assert.equal(
+ turn1Resp.functionResponse.response.result,
+ "file content from turn 1",
+ "Turn 1 functionResponse must contain turn 1 output, not turn 2 output"
+ );
+
+ // Verify Turn 2 functionCall and functionResponse
+ const turn2User = result.contents.find((c: any) =>
+ c.parts?.some(
+ (p: any) =>
+ p.functionResponse?.response?.result === "terminal output from turn 2" ||
+ p.functionResponse?.name === "run_terminal_command"
+ )
+ );
+ assert.ok(turn2User, "Turn 2 user functionResponse must exist");
+ const turn2Resp = turn2User.parts.find((p: any) => p.functionResponse);
+ assert.equal(
+ turn2Resp.functionResponse.name,
+ "run_terminal_command",
+ "Turn 2 functionResponse name must match functionCall name"
+ );
+ assert.equal(
+ turn2Resp.functionResponse.response.result,
+ "terminal output from turn 2",
+ "Turn 2 functionResponse must contain turn 2 output"
+ );
+});
+
+test("OpenAI -> Gemini pairs tool calls and responses in context mode without ID collision mismatch", () => {
+ const result = openaiToGeminiRequest(
+ "gemini-2.5-flash",
+ {
+ messages: [
+ { role: "user", content: "read file" },
+ {
+ role: "assistant",
+ content: null,
+ tool_calls: [
+ {
+ id: "call_collision_999",
+ type: "function",
+ function: { name: "read_file", arguments: '{"path":"a.txt"}' },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ tool_call_id: "call_collision_999",
+ content: "file content from turn 1",
+ },
+ { role: "user", content: "now run terminal command" },
+ {
+ role: "assistant",
+ content: null,
+ tool_calls: [
+ {
+ id: "call_collision_999",
+ type: "function",
+ function: { name: "run_terminal_command", arguments: '{"command":"ls"}' },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ tool_call_id: "call_collision_999",
+ content: "terminal output from turn 2",
+ },
+ { role: "user", content: "done" },
+ ],
+ },
+ false,
+ null,
+ { signaturelessToolCallMode: "context" }
+ ) as any;
+
+ // In context mode without thought signatures, tool responses are emitted as context text
+ const textParts = result.contents.flatMap((c: any) =>
+ (c.parts || []).filter((p: any) => typeof p.text === "string").map((p: any) => p.text)
+ );
+ assert.ok(
+ textParts.some(
+ (t: string) => t.includes("read_file") && t.includes("file content from turn 1")
+ ),
+ "Turn 1 context text must pair read_file with its own turn 1 output"
+ );
+ assert.ok(
+ textParts.some(
+ (t: string) => t.includes("run_terminal_command") && t.includes("terminal output from turn 2")
+ ),
+ "Turn 2 context text must pair run_terminal_command with its own turn 2 output"
+ );
+});
diff --git a/tests/unit/translator-reasoning-gate-502-repro.test.ts b/tests/unit/translator-reasoning-gate-502-repro.test.ts
index 4b59b90e64..0e83565dad 100644
--- a/tests/unit/translator-reasoning-gate-502-repro.test.ts
+++ b/tests/unit/translator-reasoning-gate-502-repro.test.ts
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
-function createState() {
+function createState(): Record & { requestedThinking?: boolean } {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
@@ -42,7 +42,10 @@ function flatten(items: unknown[]) {
// leaked thinking block).
test("REGRESSION guard: reasoning-only response with requestedThinking=false does NOT 502 (fix B synthesizes a text block; gate suppresses the thinking block)", () => {
- const state = createState(); // requestedThinking absent => false
+ const state = createState();
+ // "did not request" is `requestedThinking === false` — what chatCore resolves for an
+ // opted-out client. A bare state (`undefined`) is the legacy always-relay shape (#13866).
+ state.requestedThinking = false;
// GLM-5.2 autocompact: ONLY reasoning_content, no content delta.
const reasoning = openaiToClaudeResponse(
diff --git a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts
index c29f048026..5644af92f2 100644
--- a/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts
+++ b/tests/unit/translator-reasoning-gate-restore-7acddd91a.test.ts
@@ -16,6 +16,11 @@ import assert from "node:assert/strict";
// thinking-opt-out clients (requestedThinking=false) — the operator reported
// "reasoning is exposed".
//
+// NOTE (#13866 drain): "opted out" is `requestedThinking === false`, which is what
+// chatCore always resolves (hasActiveClaudeThinking() yields a boolean). A bare
+// state (`undefined`) is the LEGACY direct-caller shape and keeps the pre-#12905
+// "always relay" contract, matching the non-streaming path's own docs and the
+// #5786 suites; so these cases set the flag explicitly.
// RESOLUTION (this fix): restore the requestedThinking gate on the thinking
// block EMISSION only (content_block_start type:thinking + thinking_delta),
// so requestedThinking=false emits NO thinking block (no reasoning leak).
@@ -27,7 +32,7 @@ import assert from "node:assert/strict";
const { openaiToClaudeResponse } =
await import("../../open-sse/translator/response/openai-to-claude.ts");
-function createState() {
+function createState(): Record & { requestedThinking?: boolean } {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
@@ -44,7 +49,8 @@ function flatten(items: unknown[]) {
// a text block from the accumulated reasoning so flush has a content block (no
// 502) and Claude Code's autocompact parser has a real summary to apply.
test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a thinking block (gate) but MUST synthesize a text block (fix B) => no 502, compact applies", () => {
- const state = createState(); // requestedThinking absent => false (autocompact)
+ const state = createState();
+ state.requestedThinking = false; // client opted out (autocompact) — what chatCore resolves for it
// GLM-5.2 autocompact: ONLY reasoning_content, no content delta.
const reasoning = openaiToClaudeResponse(
@@ -108,7 +114,8 @@ test("REGRESSION: requestedThinking=false + reasoning-only MUST NOT emit a think
// accumulation; this fix keeps accumulation so fix B never false-fires (real
// content sets textBlockStarted, so the finish gate is skipped).
test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thinking block (gate) but a text block (content)", () => {
- const state = createState(); // requestedThinking absent => false
+ const state = createState();
+ state.requestedThinking = false; // client opted out — what chatCore resolves for it
const reasoning = openaiToClaudeResponse(
{
@@ -159,7 +166,8 @@ test("REGRESSION: requestedThinking=false + reasoning THEN content emits NO thin
// The accumulation MUST stay outside the gate (e28d02066 gated it too => fix B
// never fired => 502/compact loop regression).
test("REGRESSION (fix B): requestedThinking=false + reasoning-ONLY MUST synthesize a text block (NOT a thinking block) so autocompact can use it as the summary", () => {
- const state = createState(); // requestedThinking absent => false (autocompact)
+ const state = createState();
+ state.requestedThinking = false; // client opted out (autocompact) — what chatCore resolves for it
const reasoning = openaiToClaudeResponse(
{
diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts
index 5e630373e2..a57599262a 100644
--- a/tests/unit/translator-resp-openai-to-claude.test.ts
+++ b/tests/unit/translator-resp-openai-to-claude.test.ts
@@ -86,8 +86,13 @@ test("OpenAI stream: reasoning_content closes before text content starts", () =>
assert.equal(result[5].delta.text, "Answer");
});
-test("OpenAI stream: reasoning_content is suppressed by default when client did not request thinking", () => {
- const state = createState();
+test("OpenAI stream: reasoning_content is suppressed when the client did not request thinking", () => {
+ // "Did not request" is what chatCore resolves to `requestedThinking: false`
+ // (hasActiveClaudeThinking() always yields a boolean at open-sse/handlers/chatCore.ts).
+ // A bare createState() leaves it `undefined`, which is the LEGACY caller shape the
+ // non-streaming path documents as "always relay a thinking block" — so the
+ // suppression contract has to be asserted with the value production sends.
+ const state = { ...createState(), requestedThinking: false };
const reasoning = openaiToClaudeResponse(
{
id: "chatcmpl-2d",
diff --git a/tests/unit/upstream-headers-sanitize.test.ts b/tests/unit/upstream-headers-sanitize.test.ts
index b9814e2d7d..935f4d5f7d 100644
--- a/tests/unit/upstream-headers-sanitize.test.ts
+++ b/tests/unit/upstream-headers-sanitize.test.ts
@@ -1,6 +1,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { sanitizeUpstreamHeadersMap } from "../../src/lib/db/models.ts";
+import {
+ isForbiddenUpstreamHeaderName,
+ isForbiddenCustomHeaderName,
+} from "../../src/shared/constants/upstreamHeaders.ts";
test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => {
const out = sanitizeUpstreamHeadersMap({
@@ -12,6 +16,48 @@ test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => {
assert.deepEqual(out, { "X-Custom": "ok" });
});
+test("sanitizeUpstreamHeadersMap: drops origin-IP forwarding headers (no origin IP leak upstream)", () => {
+ const out = sanitizeUpstreamHeadersMap({
+ "X-Custom": "kept",
+ "X-Forwarded-For": "203.0.113.9",
+ "X-Real-IP": "203.0.113.9",
+ "CF-Connecting-IP": "203.0.113.9",
+ Forwarded: "for=203.0.113.9",
+ Via: "1.1 proxy",
+ "True-Client-IP": "203.0.113.9",
+ "X-Forwarded-Host": "origin.example.com",
+ "X-Forwarded-Proto": "https",
+ });
+ assert.deepEqual(out, { "X-Custom": "kept" });
+});
+
+test("isForbiddenUpstreamHeaderName: blocks origin-IP forwarding headers", () => {
+ for (const name of [
+ "x-forwarded-for",
+ "x-real-ip",
+ "cf-connecting-ip",
+ "forwarded",
+ "via",
+ "true-client-ip",
+ "client-ip",
+ "X-Forwarded-For",
+ "X-Real-IP",
+ "CF-Connecting-IP",
+ ]) {
+ assert.equal(isForbiddenUpstreamHeaderName(name), true, `${name} must be forbidden upstream`);
+ }
+ assert.equal(isForbiddenUpstreamHeaderName("x-custom-hdr"), false);
+});
+
+test("isForbiddenCustomHeaderName: blocks origin-IP forwarding headers for operator custom headers", () => {
+ assert.equal(isForbiddenCustomHeaderName("x-forwarded-for"), true);
+ assert.equal(isForbiddenCustomHeaderName("x-real-ip"), true);
+ assert.equal(isForbiddenCustomHeaderName("cf-connecting-ip"), true);
+ assert.equal(isForbiddenCustomHeaderName("forwarded"), true);
+ assert.equal(isForbiddenCustomHeaderName("via"), true);
+ assert.equal(isForbiddenCustomHeaderName("x-custom-hdr"), false);
+});
+
test("sanitizeUpstreamHeadersMap: drops values with CR/LF", () => {
const out = sanitizeUpstreamHeadersMap({
Good: "a",
diff --git a/tests/unit/webpack-create-require-warning.test.ts b/tests/unit/webpack-create-require-warning.test.ts
index d6fba2e315..a47be0011c 100644
--- a/tests/unit/webpack-create-require-warning.test.ts
+++ b/tests/unit/webpack-create-require-warning.test.ts
@@ -80,6 +80,9 @@ async function compileRuntimeRequireModules(): Promise {
// erroring "Can't resolve './obscura.ts'".
"./obscura.ts",
"./tlsFirstByteWatchdog.ts",
+ // machineToken.ts imports `./dataPaths` since #13909 (random per-install
+ // CLI token salt reads the data dir). Same isolated-compile reason.
+ "./dataPaths",
],
externalsPresets: { node: true },
mode: "development",