chore: sync release/v3.8.51 into the branch

This commit is contained in:
diegosouzapw
2026-09-19 01:25:35 -03:00
243 changed files with 8340 additions and 533 deletions

View File

@@ -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/`<pre>`
// 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 <pre> 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({
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
aria-label={t("copyTitle", { title })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
{!notice && (
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
aria-label={t("copyTitle", { title })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
)}
{parsedJson !== null && <JsonTreeExpandControls sectionId={resolvedSectionId} />}
</div>
</div>
{open && parsedJson !== null && (
{open && notice && (
<div className="p-4 rounded-xl border border-amber-500/40 bg-amber-500/10 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] shrink-0">warning</span>
<span>{t("payloadSizeLimitOmitted")}</span>
</div>
)}
{open && !notice && parsedJson !== null && (
<div
ref={treeContainerRef}
className="rounded-xl bg-black/5 dark:bg-black/30 border border-border max-h-150 overflow-auto p-4 text-xs font-mono"
@@ -105,7 +158,7 @@ export function PayloadSection({
/>
</div>
)}
{open && parsedJson === null && (
{open && !notice && parsedJson === null && (
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>

View File

@@ -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)}
/>
)}

View File

@@ -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;
}

View File

@@ -197,54 +197,6 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
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<string, ModelSpec> = {
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

View File

@@ -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())
);

View File

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

View File

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

View File

@@ -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);

View File

@@ -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<Response | n
const hasModelRestrictions =
apiKeyInfo.modelAccessMode === "restricted" ||
Boolean(apiKeyInfo.allowedModels?.length) ||
Boolean(apiKeyInfo.blockedModels?.length) ||
apiKeyInfo.disableNonPublicModels === true;
if (!requestedComboName && hasModelRestrictions) {
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {

View File

@@ -103,6 +103,12 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
/\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

View File

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

View File

@@ -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).

View File

@@ -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(),
})