Merge remote-tracking branch 'origin/release/v3.8.51' into fix/sec-jwt-bootstrap-chain-7pq4

This commit is contained in:
diegosouzapw
2026-09-15 16:36:07 -03:00
261 changed files with 11865 additions and 925 deletions

View File

@@ -10,7 +10,11 @@ name: Radar Export
on:
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
push:
branches: [main] # produção: só o catálogo do main clobra o asset estável
# `main` e a release ativa (default branch) publicam no mesmo asset estável: o
# radar-server só consome o asset, então um merge de catálogo na release que ficasse
# à espera do cron semanal deixava o feed até 7 dias atrás do README (2026-09-14: a
# linha da Together removida em d6e62ae só saiu do feed com dispatch manual).
branches: [main, "release/**"]
paths:
- open-sse/config/freeModelCatalog.data.ts
- open-sse/config/freeModelCatalog.ts
@@ -19,7 +23,9 @@ on:
- scripts/release/radar-export.mjs
- .github/workflows/radar-export.yml
schedule:
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
# Diário 03:17 UTC — antes do `radar-feed.timer` do servidor (04:23 UTC), para o ciclo
# do dia já enxergar o export do dia; também mantém geradoEm/proveniência frescos.
- cron: "17 3 * * *"
permissions:
contents: read

View File

@@ -56,8 +56,14 @@ explicitly:
}
```
The token can also come from the `OMNIROUTE_MANAGEMENT_API_KEY` environment
variable (the option wins when both are set). Resolution order:
`managementReadToken` option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then the
`apiKey` fallback.
Left unset, `managementReadToken` falls back to `apiKey` for backwards
compatibility. When a gateway rejects that fallback, the catalog still
compatibility, and the plugin warns once at startup that the fallback is
active. When a gateway rejects that fallback, the catalog still
publishes — but with raw model ids instead of display names, no canonical
alias dedupe, no pricing and no combos. The plugin warns once per endpoint
when this happens, naming the endpoint and the consequence, so the degraded
@@ -65,25 +71,25 @@ catalog is never a mystery.
## Options
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
| `displayName` | `"OmniRoute"` | Provider display name |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
| `managementReadToken` | option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
| `displayName` | `"OmniRoute"` | Provider display name |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
## Tool calling on Gemini models

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type {
OmniRouteEnrichmentEntry,
@@ -81,6 +81,12 @@ interface DiskSnapshotV2 {
*/
const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
// Suffix for the temp file each write publishes via rename. Monotone per
// process: two writes for one provider (for example across a credential
// rotation) must not share a temp name. Built after the empty-models and
// size-cap guards, so only real attempts consume a value.
let snapshotWriteCounter = 0;
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1;
@@ -133,7 +139,7 @@ export async function readDiskSnapshot(
if (
!parsed ||
typeof parsed.v !== "number" ||
parsed.v < SNAPSHOT_FORMAT_VERSION ||
parsed.v !== SNAPSHOT_FORMAT_VERSION ||
typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint
) {
@@ -179,8 +185,14 @@ export async function readDiskSnapshot(
export async function writeDiskSnapshot(
providerId: string,
snapshot: CatalogSnapshot,
identityFingerprint: string
identityFingerprint: string,
logger?: { warn: (message: string) => void }
): Promise<void> {
// Monotone per-process suffix: two writes for one provider (for example
// across a credential rotation) must not share a temp name. Declared here
// so the catch below can clean it up; assigned after the guards so only
// real attempts consume a counter value.
let tmp = "";
try {
if (snapshot.models.length === 0) return;
const file = diskSnapshotPath(providerId);
@@ -196,14 +208,33 @@ export async function writeDiskSnapshot(
writtenAt: Date.now(),
};
let payload = JSON.stringify(envelope);
if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) {
if (
Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES &&
envelope.enrichment !== undefined
) {
delete envelope.enrichment;
payload = JSON.stringify(envelope);
}
if (payload.length > MAX_SNAPSHOT_BYTES) return;
await writeFile(file, payload, { encoding: "utf8", mode: 0o600 });
} catch {
if (Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES) {
logger?.warn(
`[omniroute-v2] snapshot for ${providerId} exceeds the size cap, skipping disk write`
);
return;
}
tmp = `${file}.${process.pid}.${snapshotWriteCounter++}`;
await writeFile(tmp, payload, { encoding: "utf8", mode: 0o600 });
await rename(tmp, file);
} catch (err) {
// Best-effort: callers already hold the in-memory entry.
logger?.warn(
`[omniroute-v2] snapshot write failed for ${providerId}: ` +
`${err instanceof Error ? err.message : String(err)}, keeping the in-memory entry`
);
try {
await unlink(tmp);
} catch {
// Ignore: the temp file may not exist (mkdir failed first).
}
}
}

View File

@@ -31,7 +31,14 @@ import { assertContext } from "./compat.js";
import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js";
import { createSourceErrorReporter } from "./enrichment-report.js";
import { sanitizeToolSchemasFor } from "./gemini-language.js";
import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js";
import {
MANAGEMENT_TOKEN_ENV_VAR,
PLUGIN_ID,
parsePluginOptions,
resolveManagementReadToken,
resolveTimeouts,
type PluginOptions,
} from "./options.js";
/**
* A fetch result that says whether it succeeded. Returning a bare `[]` on
@@ -61,7 +68,7 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
providerId: parsed.providerId,
baseURL: parsed.baseURL,
apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "",
managementReadToken: parsed.managementReadToken,
managementReadToken: resolveManagementReadToken(parsed.managementReadToken),
timeoutMs: parsed.timeoutMs,
timeouts: parsed.timeouts,
logLevel: parsed.logLevel,
@@ -93,6 +100,16 @@ export default define({
resolved.logLevel = parsed.logLevel;
resolved.startupDebug = parsed.startupDebug;
log.info(`[omniroute-v2] init providerId=${X}`);
// The inference key stands in below when no management token is set, and
// gateways usually reject that stand-in with 401/403. Say so once here,
// before any fetch, instead of letting the refusal surface per endpoint.
if (resolved.managementReadToken === undefined) {
log.warn(
`[omniroute-v2] no management token configured: management endpoints (/api/*) will reuse the inference key, ` +
`which gateways usually reject with 401/403. Set "managementReadToken" in the plugin options ` +
`or export ${MANAGEMENT_TOKEN_ENV_VAR}.`
);
}
// v1 parity port: in-memory TTL + disk snapshot. The memory key
// `baseURL::sha256(creds)` isolates credential tuples (prod vs
@@ -297,7 +314,7 @@ export default define({
};
if (models.length > 0) {
state.entries.set(cacheKey, snapshot);
await writeDiskSnapshot(X, snapshot, identityFingerprint);
await writeDiskSnapshot(X, snapshot, identityFingerprint, log);
}
void optional.then(
(parts) => upgradeWithOptional(snapshot, parts),
@@ -344,7 +361,7 @@ export default define({
if (unchanged) return;
state.entries.set(cacheKey, upgraded);
if (upgraded.models.length > 0) {
await writeDiskSnapshot(X, upgraded, identityFingerprint);
await writeDiskSnapshot(X, upgraded, identityFingerprint, log);
}
// Reload only when the optional tier actually moved: the catalog
// fingerprint covers ids alone, so without this the host would rebuild

View File

@@ -61,6 +61,21 @@ const pluginOptionsSchema = z
export type PluginOptions = z.infer<typeof pluginOptionsSchema>;
/** Environment source for the management token (option wins over this). */
export const MANAGEMENT_TOKEN_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY";
/**
* Resolve the management token: a non-empty option wins, then a non-empty
* environment value, else absent. Empty counts as absent on both inputs, the
* same rule the inference key follows; no trimming, the token is opaque.
*/
export function resolveManagementReadToken(optionValue: string | undefined): string | undefined {
if (optionValue !== undefined && optionValue.length > 0) return optionValue;
const fromEnv = process.env[MANAGEMENT_TOKEN_ENV_VAR];
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
return undefined;
}
/** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */
export const DEFAULT_TIMEOUT_MS = 10_000 as const;
/** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */

View File

@@ -0,0 +1,251 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
diskSnapshotPath,
readDiskSnapshot,
writeDiskSnapshot,
type CatalogSnapshot,
} from "../src/cache.js";
function isolateDisk(): { dir: string; restore: () => void } {
const dir = mkdtempSync(join(tmpdir(), "omniroute-disk-atomic-"));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return {
dir,
restore: () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
},
};
}
function makeSnapshot(models: string[] = ["m-a"]): CatalogSnapshot {
return {
models: models.map((id) => ({ id })),
combos: [],
autoCombos: [],
providers: [],
fetchedAt: Date.now(),
} as unknown as CatalogSnapshot;
}
function makeLogger() {
const messages: string[] = [];
return {
messages,
logger: { warn: (message: string) => void messages.push(message) },
};
}
// Entries next to the destination other than the destination itself: any
// leftover temp file after a successful write shows up here.
function strayEntries(file: string): string[] {
let entries: string[];
try {
entries = readdirSync(dirname(file));
} catch {
return [];
}
return entries.filter((entry) => entry !== file.split("/").pop());
}
// The writer names its temp file `${file}.${pid}.${counter}` with a
// module-monotone counter starting at 0, built after the empty-models and
// size-cap guards (an over-cap call consumes no counter value). Tests in this
// file run sequentially in one process, so the attempt table below predicts
// every temp path exactly:
// over-cap: no counter use | failed write A: 0, failed write B: 1 |
// interrupted overwrite A: 2, interrupted overwrite B: 3 | mkdir failure: 4 |
// truncated read: 5 | success: 6 | permissions: 7 | round-trip: 8, 9.
function predictedTmp(file: string, counter: number): string {
return `${file}.${process.pid}.${counter}`;
}
describe("disk snapshot atomic write, strict version, traced give-ups", () => {
it("ignores a newer snapshot version without throwing", async () => {
const disk = isolateDisk();
try {
const file = diskSnapshotPath("t1-future");
mkdirSync(dirname(file), { recursive: true });
// A writer from the future persists version 3; this reader must
// treat it as "no snapshot" instead of trusting unknown data.
writeFileSync(
file,
JSON.stringify({
v: 3,
identityFingerprint: "fp-1",
models: [{ id: "m-future" }],
combos: [],
writtenAt: Date.now(),
})
);
const back = await readDiskSnapshot("t1-future", "fp-1");
assert.equal(back, undefined);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("traces an over-cap write and leaves no destination behind", async () => {
const disk = isolateDisk();
try {
const { messages, logger } = makeLogger();
const bigId = `huge-${"x".repeat(33 * 1024 * 1024)}`;
await writeDiskSnapshot("t2-cap", makeSnapshot([bigId]), "fp-1", logger);
const file = diskSnapshotPath("t2-cap");
assert.equal(existsSync(file), false);
assert.deepEqual(strayEntries(file), []);
assert.match(messages.join("\n"), /exceeds|too large|size cap/i);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a failed write leaves no destination behind and is traced", async () => {
const disk = isolateDisk();
const file = diskSnapshotPath("t3a-fail");
const blocker = predictedTmp(file, 1);
try {
const { messages, logger } = makeLogger();
await writeDiskSnapshot("t3a-fail", makeSnapshot(["m-before"]), "fp-1", logger);
// Plant a directory at the next temp path: the write fails with
// EISDIR before any rename, deterministically, on every platform.
mkdirSync(dirname(file), { recursive: true });
mkdirSync(blocker, { recursive: true });
await writeDiskSnapshot("t3a-fail", makeSnapshot(["m-after"]), "fp-1", logger);
assert.equal(existsSync(file), true);
const back = await readDiskSnapshot("t3a-fail", "fp-1");
assert.deepEqual(
(back?.models ?? []).map((entry) => entry.id),
["m-before"]
);
assert.match(messages.join("\n"), /failed|EISDIR|error/i);
} finally {
rmSync(blocker, { recursive: true, force: true });
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("an interrupted overwrite keeps the previous snapshot", async () => {
const disk = isolateDisk();
const file = diskSnapshotPath("t3b-keep");
const blocker = predictedTmp(file, 3);
try {
const { messages, logger } = makeLogger();
await writeDiskSnapshot("t3b-keep", makeSnapshot(["m-before"]), "fp-1", logger);
const before = readFileSync(file, "utf8");
mkdirSync(blocker, { recursive: true });
await writeDiskSnapshot("t3b-keep", makeSnapshot(["m-after"]), "fp-1", logger);
assert.equal(readFileSync(file, "utf8"), before);
const back = await readDiskSnapshot("t3b-keep", "fp-1");
assert.deepEqual(
(back?.models ?? []).map((entry) => entry.id),
["m-before"]
);
assert.match(messages.join("\n"), /failed|EISDIR|error/i);
} finally {
rmSync(blocker, { recursive: true, force: true });
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a mkdir failure is traced and writes nothing", async () => {
const disk = isolateDisk();
try {
const { messages, logger } = makeLogger();
// A file planted at the plugins path makes mkdir fail
// deterministically (EEXIST on mkdir, ENOTDIR on direct writeFile).
writeFileSync(join(disk.dir, "plugins"), "blocker");
await writeDiskSnapshot("t3b-bis", makeSnapshot(["m-a"]), "fp-1", logger);
assert.equal(existsSync(diskSnapshotPath("t3b-bis")), false);
assert.match(messages.join("\n"), /failed|EEXIST|ENOTDIR|error/i);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a truncated file reads as no snapshot without throwing", async () => {
const disk = isolateDisk();
try {
const { logger } = makeLogger();
await writeDiskSnapshot("t4-truncated", makeSnapshot(["m-a"]), "fp-1", logger);
const file = diskSnapshotPath("t4-truncated");
const full = readFileSync(file, "utf8");
writeFileSync(file, full.slice(0, Math.floor(full.length / 2)));
const back = await readDiskSnapshot("t4-truncated", "fp-1");
assert.equal(back, undefined);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a successful write leaves no entry but the destination", async () => {
const disk = isolateDisk();
try {
await writeDiskSnapshot("t5-clean", makeSnapshot(["m-a"]), "fp-1");
const file = diskSnapshotPath("t5-clean");
assert.deepEqual(strayEntries(file), []);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("the replaced snapshot stays owner-only", async (t) => {
if (process.platform === "win32") {
t.skip("file mode semantics are POSIX-only");
return;
}
const disk = isolateDisk();
try {
await writeDiskSnapshot("t6-mode", makeSnapshot(["m-a"]), "fp-1");
const file = diskSnapshotPath("t6-mode");
assert.equal((statSync(file).mode & 0o077) === 0, true);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("round-trips a valid snapshot with and without a logger", async () => {
const disk = isolateDisk();
try {
const { logger } = makeLogger();
const snapshot = makeSnapshot(["m-a"]);
await writeDiskSnapshot("t7-roundtrip", snapshot, "fp-1");
const plain = await readDiskSnapshot("t7-roundtrip", "fp-1");
assert.deepEqual(
(plain?.models ?? []).map((entry) => entry.id),
["m-a"]
);
await writeDiskSnapshot("t7-roundtrip", snapshot, "fp-1", logger);
const logged = await readDiskSnapshot("t7-roundtrip", "fp-1", logger);
assert.deepEqual(
(logged?.models ?? []).map((entry) => entry.id),
["m-a"]
);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,371 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
const PRICING_MODELS_URL = "https://gw.example.com/api/pricing/models";
const MGMT_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY";
const INFERENCE_ENV_VAR = "OMNIROUTE_API_KEY";
function okJson(body: unknown) {
return { ok: true, status: 200, statusText: "OK", json: async () => body };
}
interface Harness {
seen: Map<string, string>;
warns: string[];
restore: () => void;
}
function installHarness(combos: unknown[]): Harness {
const seen = new Map<string, string>();
const warns: string[] = [];
const origFetch = globalThis.fetch;
const origWarn = console.warn;
const origLog = console.log;
const origError = console.error;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
console.error = (...args: unknown[]) => {
warns.push(String(args[0]));
};
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
const href = String(url);
seen.set(href, String(init?.headers?.Authorization ?? ""));
if (href.includes("/api/combos/auto")) return okJson({ combos: [] });
if (href.includes("/api/pricing/models")) {
return okJson({
providers: {
demo: {
id: "demo",
name: "Demo",
models: [{ id: "team-combo", name: "Team Combo" }],
},
},
});
}
if (href.includes("/api/pricing")) return okJson({});
if (href.includes("/api/free-tier/summary")) return okJson({ perModel: [] });
if (href.includes("/api/combos")) return okJson({ combos });
return okJson({ data: [{ id: "m1" }] });
}) as typeof fetch;
return {
seen,
warns,
restore() {
globalThis.fetch = origFetch;
console.warn = origWarn;
console.log = origLog;
console.error = origError;
},
};
}
async function withIsolatedEnv<T>(
mgmt: string | undefined,
inference: string | undefined,
fn: () => Promise<T>
): Promise<T> {
const prevMgmt = process.env[MGMT_ENV_VAR];
const prevInference = process.env[INFERENCE_ENV_VAR];
// Like tests/management-token.test.ts:176-180: a fresh OPENCODE_DATA_DIR
// per case keeps the real disk snapshot out of the run, so a filtered 'it'
// never gets a warm snapshot served without fetch.
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-mgmt-env-"));
if (mgmt === undefined) delete process.env[MGMT_ENV_VAR];
else process.env[MGMT_ENV_VAR] = mgmt;
if (inference === undefined) delete process.env[INFERENCE_ENV_VAR];
else process.env[INFERENCE_ENV_VAR] = inference;
try {
return await fn();
} finally {
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
if (prevMgmt === undefined) delete process.env[MGMT_ENV_VAR];
else process.env[MGMT_ENV_VAR] = prevMgmt;
if (prevInference === undefined) delete process.env[INFERENCE_ENV_VAR];
else process.env[INFERENCE_ENV_VAR] = prevInference;
}
}
function setupHarness(options: Record<string, unknown>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
}
function stubDraft() {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const key = pid + "/" + mid;
let entry = published.get(key);
if (entry === undefined) {
entry = { id: mid, providerID: pid };
published.set(key, entry);
}
fn(entry);
},
},
};
return { draft, published };
}
function fallbackWarns(warns: string[]): string[] {
return warns.filter((w) => w.includes("managementReadToken"));
}
async function runSetup(ctx: unknown): Promise<void> {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
}
describe("plugin-v2 management token environment source", () => {
it("uses the managementReadToken option for /api/* while models keep apiKey", async () => {
await withIsolatedEnv(undefined, undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-option-token",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key");
} finally {
h.restore();
}
});
});
it("reads the management token from the environment when the option is absent", async () => {
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token");
assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key");
} finally {
h.restore();
}
});
});
it("prefers the option over the environment", async () => {
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-option-token",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
} finally {
h.restore();
}
});
});
it("falls back to the inference key with a single early warning when neither is set", async () => {
await withIsolatedEnv(undefined, undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await runSetup(ctx);
const atSetup = fallbackWarns(h.warns);
assert.equal(
atSetup.length,
1,
`expected exactly one early fallback warning, got: ${JSON.stringify(h.warns)}`
);
assert.match(atSetup[0] ?? "", /managementReadToken/);
assert.match(atSetup[0] ?? "", new RegExp(MGMT_ENV_VAR));
assert.ok(!(atSetup[0] ?? "").includes("chat-key"), "warning must not leak the key");
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key");
assert.equal(
fallbackWarns(h.warns).length,
1,
"the fallback warning stays a single setup-time notice"
);
} finally {
h.restore();
}
});
});
it("treats an empty option as absent so the environment wins", async () => {
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token");
} finally {
h.restore();
}
});
});
it("treats an empty environment value as absent so the option wins", async () => {
await withIsolatedEnv("", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-option-token",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
} finally {
h.restore();
}
});
});
it("falls back with a warning when both the option and the environment are empty", async () => {
await withIsolatedEnv("", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "",
});
await runSetup(ctx);
assert.equal(fallbackWarns(h.warns).length, 1);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key");
} finally {
h.restore();
}
});
});
it("enriches the catalog from the environment token alone", async () => {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as unknown as CatalogDraft;
let seenCombos = "";
let seenPricing = "";
const res = await withIsolatedEnv("mgmt-env-token", undefined, async () =>
publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "chat-key",
managementReadToken: process.env[MGMT_ENV_VAR],
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
},
{
fetcher: async () => [{ id: "m1" }],
combosFetcher: async (_base, token) => {
seenCombos = token;
return [{ id: "team-combo", models: [{ kind: "model", model: "m1" }] }];
},
enrichmentFetcher: async (_base, token) => {
seenPricing = token;
// The process env is the source under test: the resolver output
// flows in through the option above, so report success only when
// the flow under test actually carried it.
if (token !== "mgmt-env-token") return new Map();
return new Map([["team-combo", { name: "Team Combo" }]]);
},
}
)
);
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
assert.equal(seenCombos, "mgmt-env-token");
assert.equal(seenPricing, "mgmt-env-token");
const entry = models.get("omniroute/team-combo");
assert.ok(entry, "expected the combo entry in the published catalog");
assert.equal(entry?.name, "Team Combo");
});
});

View File

@@ -0,0 +1 @@
- **fix(api):** `/v1/files` and `/v1/batches` now enforce one ownership rule everywhere — a dashboard session is the instance operator, an API key acts on its own records only, and a record with no owner is denied to every non-session caller. Previously a file or batch whose `api_key_id` was null (a dashboard-session or anonymous upload, or a batch artifact inheriting one) could be read, downloaded, deleted, cancelled or used as a batch input by any other key or by an unauthenticated caller (GHSA-2jm2-mpx8-6523), and `GET /v1/files` / `GET /v1/batches` returned every tenant's records to an anonymous or invalid-bearer caller under the default `REQUIRE_API_KEY=false` (GHSA-m3hp-hq9g-fpmv) — both lists now fail closed with a `401`, and only a dashboard session without a key reads the whole instance. The same shared rule lets the dashboard cancel any batch, not just unowned ones. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, since a null owner cannot be attributed. Subsumes [#13683](https://github.com/diegosouzapw/OmniRoute/pull/13683) — thanks @hartmark

View File

@@ -0,0 +1 @@
- **fix(combos):** stop dropping live keys and persisting dead ones ([#13217](https://github.com/diegosouzapw/OmniRoute/pull/13217)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(db):** the WAL checkpoint busy counter reported by `/api/monitoring/health` now survives restarts — busy checkpoints are counted in memory and persisted from the next clean maintenance tick or at shutdown, never with a write while the database is contended ([#13218](https://github.com/diegosouzapw/OmniRoute/pull/13218)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(combos):** testing a combo aborts in-flight probes when the client disconnects instead of probing on after the dashboard navigates away ([#13279](https://github.com/diegosouzapw/OmniRoute/pull/13279)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(quota):** in-process routing and quota caches (quality tracker, saturation and rate-limit header caches, quota-fetcher cache, learned rate limits, account buckets) are now size-bounded through one shared `boundedMap` — caps sit far above normal deployments, evictions are logged once per minute per cache instead of per entry, and state whose loss would change routing (live saturated quota buckets, evaluator quality scores) is never evicted ([#13280](https://github.com/diegosouzapw/OmniRoute/pull/13280)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(call-logs):** call-log error types are now a versioned vocabulary (`ERROR_TYPE_CONTRACT v1`) with explicit `unknown` instead of ambiguous `null`, and free-text history reads back as `unclassified` ([#13281](https://github.com/diegosouzapw/OmniRoute/pull/13281)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(build):** the production build no longer breaks when a client component reaches a server-only module, and the client-bundle guard now discovers server-only modules instead of matching a fixed list ([#13436](https://github.com/diegosouzapw/OmniRoute/pull/13436)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(models):** return a retryable 503 with Retry-After instead of a 500 when the first catalog build outlasts its time bound ([#13438](https://github.com/diegosouzapw/OmniRoute/pull/13438)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(combo):** new opt-in flag `PROTECTED_PRIORITY_INFRA_502_ENABLED` (default off): when a priority target marked fallback-only-on-quota-exhaustion stops the combo because its provider circuit breaker is open or a predictive latency check rejected it — causes that are provably not quota — the response is 502 instead of a quota-looking 503; lockout, cooldown, unavailable, exhaustion, credential-gate and concurrency-cap stops keep 503 ([#13439](https://github.com/diegosouzapw/OmniRoute/pull/13439)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(resilience):** non-TPD daily-quota cooldowns honor the provider node's configured daily-reset clock (timezone + hour) instead of server midnight, on single-model and combo (priority and round-robin) paths; timezone edits apply without a restart ([#13440](https://github.com/diegosouzapw/OmniRoute/pull/13440)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(call-logs):** the call-log write point validates `error_type` against the versioned vocabulary with a Zod schema and stores `unknown` for any value outside it, so a classifier family that drifts from `ERROR_TYPE_CONTRACT` can never persist free text ([#13441](https://github.com/diegosouzapw/OmniRoute/pull/13441)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(providers):** Muse Spark 1.3 works on OpenCode Zen, OpenCode and OpenCode Go instead of failing with a 500, and gets its real 1M context window ([#13471](https://github.com/diegosouzapw/OmniRoute/pull/13471)) — thanks @maxmad64bis (with thanks to @bacnh85, @shermzy and @atakhadiviom for #12675, #12973 and #13111)

View File

@@ -0,0 +1 @@
- **fix(proxies):** a subscription refresh, a bulk re-import or an API update that omits the status no longer turns a disabled proxy back on, and a refresh no longer rewrites a manual proxy that shares a subscription node's address ([#13577](https://github.com/diegosouzapw/OmniRoute/pull/13577)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(api):** a partial update no longer resets the fields the client did not send: renaming a disabled reasoning routing rule keeps it disabled with its priority, description and tags, renaming a playground preset keeps its params, and renaming or re-importing a proxy keeps its address family ([#13582](https://github.com/diegosouzapw/OmniRoute/pull/13582)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(proxy):** proxy credentials holding a literal `%` (e.g. `pa%ss`) no longer break the proxy — HTTP(S) proxies now receive a correctly built `Proxy-Authorization` header instead of undici throwing `URIError`, SOCKS5 proxies get the raw credential, and the proxy registry, subscription import and legacy settings parsers keep the value instead of dropping the entry; correctly percent-encoded credentials decode exactly as before ([#13605](https://github.com/diegosouzapw/OmniRoute/pull/13605)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(connection-cooldown):** skip connection cooldown for locally rejected token-budget 429s so a per-key limit never cools a healthy connection ([#13606](https://github.com/diegosouzapw/OmniRoute/pull/13606)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(opencode-plugin-v2):** write the catalog snapshot to a temp file and rename it into place, ignore newer snapshot versions, and warn when a write is skipped or fails ([#13607](https://github.com/diegosouzapw/OmniRoute/pull/13607)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(proxies):** pool validation no longer rewrites proxies set to inactive or dead; only active and error statuses are updated ([#13612](https://github.com/diegosouzapw/OmniRoute/pull/13612)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(opencode):** the v2 plugin reads the management token from OMNIROUTE_MANAGEMENT_API_KEY (plugin option wins) and warns once at startup when management calls fall back to the inference key ([#13613](https://github.com/diegosouzapw/OmniRoute/pull/13613)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(routing):** a failed stale-pin (LKGP) clear on the combo fallback path now logs the combo and execution key while staying non-blocking, and a new opt-in `npm run check:routing-error-guard` script (not wired into CI) flags new swallowed catches and unanchored fire-and-forget async on routing paths, with frozen entries keyed by file and catch body instead of line numbers ([#13614](https://github.com/diegosouzapw/OmniRoute/pull/13614)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(stream-recovery):** opt-in `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` (default off) makes mid-stream continuation tool-call safe — a cut stream is never resumed once a tool call was emitted, whether still in flight or already finished with `finish_reason: "tool_calls"` — and closes after one empty continuation instead of spending the whole budget ([#13633](https://github.com/diegosouzapw/OmniRoute/pull/13633)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(db):** search stats and analytics no longer surface "ghost" rows — a NULL/`-` provider or a keyed search provider whose connection was deleted — while keyless providers (`duckduckgo-free`, `searxng-search`, anonymous `context7`) and credential-fallback providers (`perplexity-search` on a `perplexity` key) stay visible; the analytics totals apply the same filter, so `total` always matches the per-provider breakdown ([#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(dashboard):** new opt-in flag `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` (default off) makes the provider-page Free badge strict — it drops the display-name heuristic, non-boolean `free` fields and `:free` suffixes on registered providers without a documented free tier, while keeping catalogued free models, explicit `free: true` and `:free` on free-tier providers and compatible nodes; with the flag off the badges are unchanged ([#13645](https://github.com/diegosouzapw/OmniRoute/pull/13645)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(api):** share the single SOCKS5 flag reader across the settings proxy routes so the dashboard and the dispatcher stay consistent ([#13646](https://github.com/diegosouzapw/OmniRoute/pull/13646)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(stream-recovery):** log every mid-stream continuation outcome with its `attempt N/MAX` token — the stitched suffix, overlap rejection, terminal/empty continuation and tool-call refusals at debug, and a recovery that gives up (budget spent, or the continuation request returned no stream) at warn — without adding any warn line to a healthy or tool-call stream; the existing `mid-stream continuation attempt N/MAX` line is unchanged ([#13650](https://github.com/diegosouzapw/OmniRoute/pull/13650)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(sse):** a configured daily-quota reset hour that falls inside a daylight-saving gap (New York 02:00 on spring-forward, Havana/Santiago midnight) now resolves to the first wall-clock time that exists instead of landing an hour early, sometimes on the previous day ([#13671](https://github.com/diegosouzapw/OmniRoute/pull/13671)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(sse):** new opt-in flag `RETRY_AFTER_PROVENANCE_ENABLED` (default off): aggregated 429/503 unavailable responses omit `Retry-After` when no concrete future retry time is known instead of sending a synthetic 1s, carry `error.retry_after_provenance` (`signal` | `none`), and combo drain paths read prose retry hints from JSON and plain-text upstream bodies; non-JSON upstream error pages no longer log at warn ([#13672](https://github.com/diegosouzapw/OmniRoute/pull/13672)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(usage):** mark locally estimated token usage in the call log (`_omniroute.usageEstimated` on the logged response) so operators can tell estimated counts and costs from provider-reported ones — covers OmniRoute's own estimate for streams without upstream usage and web executors that report `estimated: true`; billing, API-key budgets, quota-share and client payloads are unchanged ([#13686](https://github.com/diegosouzapw/OmniRoute/pull/13686)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(security):** client-supplied image URLs (`image_url` / `mask_url` / message parts on image generation and upscale, chat `image_url` parts inlined by the vision bridge) and the NanoBanana result download now pin the `public-only` outbound guard with DNS validation, instead of inheriting the operator provider policy — `block-metadata` on a default install let a request body make the server fetch loopback/LAN URLs and forward the bytes upstream (GHSA-34rg-3pqj-35g9)

View File

@@ -0,0 +1 @@
- **fix(authz):** classify the 14 remaining spawn-capable `/api/cli-tools/*` routes (`all-statuses`, `status`, `detect` and the `claude/cline/codewhale/codex/crush/deepseek-tui/droid/kilo/openclaw/pi/smelt-settings` writers) and the `/api/skills/install` + `/api/skills/executions` pair as LOCAL_ONLY — they reach `child_process.spawn` transitively (`getCliRuntimeStatus()` / `detectAllTools()` / the skills sandbox) but only sat behind Tier 3 MANAGEMENT auth, which `requireLogin=false` waives; loopback/LAN enforcement now runs before any auth check, matching their already-gated siblings (GHSA-35fw-cv32-2373 — thanks Parth Narula; GHSA-jx89-f37j-pq89 — thanks Aeon). Tunnel-served dashboards lose the CLI Tools status badges, the same trade-off already accepted for grok/forge/jcode/qwen.

View File

@@ -0,0 +1 @@
- **fix(security):** redact Groq (`gsk_…`), xAI (`xai-…`) and every OpenAI-compatible `sk-…` key shape (DeepSeek 32-hex, Moonshot/Kimi, Together, …) in error bodies and the opt-in credential-masker guardrail — the catalog only knew the exact 48-char OpenAI form, so those keys passed through the guardrail verbatim and `gsk_`/`xai-` also reached public error responses (GHSA-r4q7-7f24-m29p)

View File

@@ -0,0 +1 @@
- **fix(security):** bump the `adm-zip` override to `^0.6.1` — 0.6.0 followed a symlink already present inside the extraction root and could write outside it (GHSA-vwc7-r8mq-g2x9 / CVE-2026-76845); 0.6.1 walks every path component with `lstat` and refuses symlinks. Reached only through `onnxruntime-node`'s install script, which unpacks the vendor's own binary — no request-path exposure.

View File

@@ -0,0 +1,2 @@
- **test(batches):** the two seeded-batch labels of the delete-completed route-scope suite that sat right after a `key*.id` argument are renamed to short literals (`route401`/`route500`), so a gitleaks scan that reads those lines (full-tree, or git-mode on a branch that adds them) no longer reports them as `generic-api-key` hits ([#13729](https://github.com/diegosouzapw/OmniRoute/pull/13729))
— no gate changes: the CI secret ratchet scans `src`/`open-sse`/`bin`/`electron`/`scripts`, never `tests/`

View File

@@ -469,7 +469,7 @@
},
"open-sse/services/combo.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 21
"count": 1
}
},
"open-sse/services/combo/providerWildcard.ts": {
@@ -585,7 +585,7 @@
},
"open-sse/services/rateLimitManager.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
"count": 1
}
},
"open-sse/services/routing/index.ts": {
@@ -1284,11 +1284,6 @@
"count": 10
}
},
"src/app/api/v1/models/catalogCache.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/api/v1/models/catalogOpenrouter.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1

View File

@@ -1,4 +1,7 @@
{
"_rebaseline_2026_09_15_13439_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/combo/executeTargetAttempt.ts->1228. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
"_rebaseline_2026_09_15_13672_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/combo/executeTargetAttempt.ts->1223; open-sse/services/combo/roundRobinCombo.ts->1213. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
"_rebaseline_2026_09_15_13441_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/lib/db/core.ts->1770. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
"_rebaseline_2026_09_14_13547_noauth_model_lockout": "PR #13547 own growth: src/sse/services/auth.ts 3542->3556 (+14). The synthetic noauth connection short-circuited before the per-connection status pass, so a recorded model-only lockout was never enforced and the locked model was retried on every request. Irreducible at the early-return site. Covered by tests/unit/noauth-model-lockout.test.ts (carried from #13527).",
"_rebaseline_2026_09_14_13404_healthcheck_backup_prune": "PR #13404 own growth: src/lib/db/core.ts 1745->1767 (+22). createManagedDbBackup (the health-check-repair snapshot path) never ran retention, so every restart of a healthy DB added a full-size copy to db_backups/; it now prunes with the same env-driven limits as backup.ts, importing backupRetention directly to avoid the backup.ts cycle. Covered by tests/unit/db-backup-healthcheck-prune-13308.test.ts.",
"_rebaseline_2026_09_14_13349_virtualfactory_custom_models_guard": "PR #13349 own growth: open-sse/services/autoCombo/virtualFactory.ts 1219->1230 (+11). The customModels key_value blob is operator-writable raw JSON, so a null or non-object row null-derefed every read and no auto/* pool could materialize; the builder now filters rows the same way catalog.ts already does. Irreducible at the read site. Covered by tests/unit/combo-auto-pool-visible-only.test.ts.",
@@ -429,6 +432,9 @@
"_rebaseline_2026_08_29_11481_model_exposure_list": "Feature #11481 (explicit model exposure allow/deny list for /v1/models, mirrored into auto/* combo pools) own growth on top of #9133's +1: open-sse/services/autoCombo/virtualFactory.ts 1139->1145 (measured real line count after both #9133 and #11481 merged together = one import line for filterModelExposureCandidates plus the filter-and-reassign block at the existing buildPreparedPool chokepoint, immediately after the filterPaidOnlyCandidates call it mirrors — the exact pattern #6512 already established for hidePaidModels). The actual predicate (isModelExposureAllowed, glob support via the shared globToRegex matcher) lives in the new src/shared/utils/modelExposureList.ts leaf, and the pool-filter wrapper lives in the new open-sse/services/autoCombo/modelExposureFilter.ts leaf (both well under cap) — this file only carries the minimal call-site wiring plus import, not extractable further without hiding the buildPreparedPool filter chain. Covered by tests/unit/autoCombo/model-exposure-filter-11481.test.ts (pure filter, all branches) and tests/unit/model-exposure-list.test.ts (predicate).",
"_rebaseline_2026_08_29_9133_candidates_inspector_skip_flag": "#9133 own growth: open-sse/services/autoCombo/virtualFactory.ts 1138->1139 (+1, net of extraction). Fix: prepareVirtualAutoComboInputs gained an opt-in `skip` parameter so the read-only #7819 candidate inspector (open-sse/handlers/autoComboCandidates.ts) can build the FULL, unfiltered pool and decorate a resilience-blocked candidate as reachable:false instead of filterResilienceBlockedCandidates silently dropping the row before the inspector ever sees it (routing is unaffected — it never passes `skip`). The connectionsById map-building loop was extracted to buildConnectionResilienceMap() in resilienceCandidateFilter.ts (net 0 there since Prettier still breaks the call over multiple lines) and the now-unused ConnectionResilienceView import was dropped; the sole remaining growth is the new `skip` default parameter itself, which Prettier always places on its own line once the preceding options object parameter already breaks across lines — not further reducible without splitting prepareVirtualAutoComboInputs's signature away from its own body. Covered by tests/unit/auto-combo-candidates-locked-model-visible.test.ts (TDD repro: red before the fix, green after) plus the existing tests/unit/noauth-autocombo-lockout-7623.test.ts and tests/unit/auto-combo-credentialed-model-pool.test.ts (unaffected routing-path behavior).",
"_rebaseline_2026_08_30_11703_json_tree_viewer": "/merge-batch 2026-08-30 (v3.8.51): #11703 (hartmark) own growth: src/shared/components/RequestLoggerDetail.tsx 1018->1111 (+93). The 2026-07-22 annotation on this same file said 'no further growth without split rationale' — this PR does split: the collapsible-JSON-tree rendering logic itself lives in the sibling RequestLoggerDetail.sections.tsx (PayloadSection/StreamSection extraction, +82 lines there) plus two new leaves (JsonTreeExpandControls.tsx, useTimestampTitles.ts) and a new store (jsonTreeExpandStore.ts) — all well under cap. The +93 remaining here is the irreducible call-site wiring: import + mount JsonTreeExpandControls, wire the per-section expand-level state and timestamp-tooltip hook into the existing detail panel layout. Covered by the PR's own tests/unit/dashboard/payload-section-collapsible-json.test.tsx, timestamp-titles.test.tsx, tests/unit/shared/json-tree-expand-store.test.ts, short-call-id.test.ts (43/43 vitest + 11/11 native pass).",
"_rebaseline_2026_09_15_13440_daily_reset_tz": "#13440 rework: open-sse/services/accountFallback.ts 2469->2493 (+24): +6 for the operator-clock-first branch in checkFallbackError non-TPD daily quota (nextConfiguredResetMs leaf lives in dailyQuotaReset.ts, under cap) and +18 from the mandatory lint-staged Prettier pass over pre-existing unformatted lines of the touched file (no logic). executeTargetAttempt.ts 1212->1215 and roundRobinCombo.ts 1205->1208 (+3 each): one import plus the rotation/dailyReset arguments at the existing checkFallbackError call site; the lookup itself is the new comboDailyResetClock.ts leaf (under cap). Covered by tests/unit/daily-reset-tz-threading.test.ts.",
"_rebaseline_2026_09_15_13672_retry_after_provenance": "#13672 rework (opt-in RETRY_AFTER_PROVENANCE_ENABLED): open-sse/services/combo/executeTargetAttempt.ts 1212->1220 (+8) and roundRobinCombo.ts 1205->1210 (+5) at the existing drain-path clone/parse block: capture the already-read body text, log an unreadable hint (debug for a non-JSON page, warn for a failed clone) instead of an empty catch, and one flag-gated prose fallback line; the import grows by the two helpers. Parsing, flag read and the Retry-After/provenance logic live in open-sse/utils/error.ts (under cap). Covered by tests/unit/retry-after-provenance.test.ts (flag off and on).",
"_rebaseline_2026_09_15_13439_protected_priority_stop_status": "#13439 rework (opt-in PROTECTED_PRIORITY_INFRA_502_ENABLED): open-sse/services/combo/executeTargetAttempt.ts 1212->1217 (+5): two import lines for the new protectedPriorityStopStatus.ts leaf (where the provably-non-quota cause list and the flag read live) and the predictive_ttft cause argument at the existing stopProtectedPriorityTarget call, which Prettier splits over three lines. Covered by tests/unit/combo/protected-priority-stop-status-13439.test.ts (every stop cause, flag off and on).",
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
"open-sse/executors/antigravity.ts": 1665,
@@ -442,10 +448,10 @@
"open-sse/handlers/search.ts": 1789,
"open-sse/mcp-server/schemas/tools.ts": 1621,
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2469,
"open-sse/services/accountFallback.ts": 2493,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
"open-sse/services/combo.ts": 4080,
"open-sse/services/combo/executeTargetAttempt.ts": 1212,
"open-sse/services/combo/executeTargetAttempt.ts": 1228,
"open-sse/translator/response/openai-responses.ts": 1466,
"open-sse/utils/cursorAgentProtobuf.ts": 1547,
"open-sse/utils/proxyFetch.ts": 1271,
@@ -470,7 +476,7 @@
"src/app/api/v1/models/catalog.ts": 2075,
"src/app/docs/lib/openapi.generated.ts": 1347,
"src/lib/db/apiKeys.ts": 1625,
"src/lib/db/core.ts": 1767,
"src/lib/db/core.ts": 1770,
"src/lib/db/migrationRunner.ts": 1206,
"src/lib/tailscaleTunnel.ts": 1208,
"src/lib/tokenHealthCheck.ts": 1218,
@@ -482,7 +488,7 @@
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656,
"open-sse/services/autoCombo/virtualFactory.ts": 1230,
"open-sse/services/combo/roundRobinCombo.ts": 1205
"open-sse/services/combo/roundRobinCombo.ts": 1213
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -3999,12 +3999,14 @@ paths:
get:
tags: [CLI Tools]
summary: Get Claude CLI settings
x-loopback-only: true
responses:
"200":
description: Claude CLI configuration
post:
tags: [CLI Tools]
summary: Apply Claude CLI settings
x-loopback-only: true
requestBody:
required: true
content:
@@ -4017,6 +4019,7 @@ paths:
delete:
tags: [CLI Tools]
summary: Reset Claude CLI settings
x-loopback-only: true
responses:
"200":
description: Claude CLI settings reset
@@ -4025,12 +4028,14 @@ paths:
get:
tags: [CLI Tools]
summary: Get Cline CLI settings
x-loopback-only: true
responses:
"200":
description: Cline CLI configuration
post:
tags: [CLI Tools]
summary: Apply Cline CLI settings
x-loopback-only: true
requestBody:
required: true
content:
@@ -4043,6 +4048,7 @@ paths:
delete:
tags: [CLI Tools]
summary: Reset Cline CLI settings
x-loopback-only: true
responses:
"200":
description: Cline CLI settings reset
@@ -4093,12 +4099,14 @@ paths:
get:
tags: [CLI Tools]
summary: Get Codex CLI settings
x-loopback-only: true
responses:
"200":
description: Codex CLI configuration
post:
tags: [CLI Tools]
summary: Apply Codex CLI settings
x-loopback-only: true
requestBody:
required: true
content:
@@ -4111,6 +4119,7 @@ paths:
delete:
tags: [CLI Tools]
summary: Reset Codex CLI settings
x-loopback-only: true
responses:
"200":
description: Codex CLI settings reset
@@ -4119,12 +4128,14 @@ paths:
get:
tags: [CLI Tools]
summary: Get Droid CLI settings
x-loopback-only: true
responses:
"200":
description: Droid CLI configuration
post:
tags: [CLI Tools]
summary: Apply Droid CLI settings
x-loopback-only: true
requestBody:
required: true
content:
@@ -4137,6 +4148,7 @@ paths:
delete:
tags: [CLI Tools]
summary: Reset Droid CLI settings
x-loopback-only: true
responses:
"200":
description: Droid CLI settings reset
@@ -4145,12 +4157,14 @@ paths:
get:
tags: [CLI Tools]
summary: Get Kilo CLI settings
x-loopback-only: true
responses:
"200":
description: Kilo CLI configuration
post:
tags: [CLI Tools]
summary: Apply Kilo CLI settings
x-loopback-only: true
requestBody:
required: true
content:
@@ -4163,6 +4177,7 @@ paths:
delete:
tags: [CLI Tools]
summary: Reset Kilo CLI settings
x-loopback-only: true
responses:
"200":
description: Kilo CLI settings reset
@@ -4171,12 +4186,14 @@ paths:
get:
tags: [CLI Tools]
summary: Get OpenClaw CLI settings
x-loopback-only: true
responses:
"200":
description: OpenClaw CLI configuration
post:
tags: [CLI Tools]
summary: Apply OpenClaw CLI settings
x-loopback-only: true
requestBody:
required: true
content:
@@ -4189,6 +4206,7 @@ paths:
delete:
tags: [CLI Tools]
summary: Reset OpenClaw CLI settings
x-loopback-only: true
responses:
"200":
description: OpenClaw CLI settings reset
@@ -8256,6 +8274,7 @@ paths:
tags:
- CLI Tools
summary: Read Crush CLI OmniRoute config
x-loopback-only: true
description: Local-only. Reads the OmniRoute provider block in Crush's config.
x-internal: true
responses:
@@ -8265,6 +8284,7 @@ paths:
tags:
- CLI Tools
summary: Write Crush CLI OmniRoute config
x-loopback-only: true
description: Local-only. Registers OmniRoute as an `openai-compat` provider in Crush's config.
x-internal: true
responses:
@@ -8274,6 +8294,7 @@ paths:
tags:
- CLI Tools
summary: Remove OmniRoute from Crush CLI config
x-loopback-only: true
description: Local-only. Removes the OmniRoute provider block from Crush's config.
x-internal: true
responses:
@@ -8284,6 +8305,7 @@ paths:
tags:
- CLI Tools
summary: Read CodeWhale CLI OmniRoute config
x-loopback-only: true
description: >-
Local-only. Reads the OmniRoute config block from
`~/.codewhale/config.toml` (with `~/.deepseek/config.toml` legacy
@@ -8296,6 +8318,7 @@ paths:
tags:
- CLI Tools
summary: Write CodeWhale CLI OmniRoute config
x-loopback-only: true
description: Local-only. Writes the OmniRoute config block in CodeWhale TOML format.
x-internal: true
responses:
@@ -8305,6 +8328,7 @@ paths:
tags:
- CLI Tools
summary: Remove OmniRoute from CodeWhale CLI config
x-loopback-only: true
description: Local-only. Removes the OmniRoute config block from CodeWhale's config.
x-internal: true
responses:
@@ -8566,6 +8590,7 @@ paths:
tags:
- Cli tools
summary: "GET cli tools all statuses"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8597,6 +8622,7 @@ paths:
tags:
- Cli tools
summary: "DELETE cli tools deepseek tui settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8604,6 +8630,7 @@ paths:
tags:
- Cli tools
summary: "GET cli tools deepseek tui settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8611,6 +8638,7 @@ paths:
tags:
- Cli tools
summary: "POST cli tools deepseek tui settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8619,6 +8647,7 @@ paths:
tags:
- Cli tools
summary: "GET cli tools detect"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8791,6 +8820,7 @@ paths:
tags:
- Cli tools
summary: "DELETE cli tools pi settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8798,6 +8828,7 @@ paths:
tags:
- Cli tools
summary: "GET cli tools pi settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8805,6 +8836,7 @@ paths:
tags:
- Cli tools
summary: "POST cli tools pi settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8838,6 +8870,7 @@ paths:
tags:
- Cli tools
summary: "DELETE cli tools smelt settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8845,6 +8878,7 @@ paths:
tags:
- Cli tools
summary: "GET cli tools smelt settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8852,6 +8886,7 @@ paths:
tags:
- Cli tools
summary: "POST cli tools smelt settings"
x-loopback-only: true
responses:
"200":
description: OK
@@ -8860,6 +8895,7 @@ paths:
tags:
- Cli tools
summary: "GET cli tools status"
x-loopback-only: true
responses:
"200":
description: OK
@@ -11715,6 +11751,7 @@ paths:
tags:
- Skills
summary: "GET skills executions"
x-loopback-only: true
responses:
"200":
description: OK
@@ -11722,6 +11759,7 @@ paths:
tags:
- Skills
summary: "POST skills executions"
x-loopback-only: true
responses:
"200":
description: OK
@@ -11730,6 +11768,7 @@ paths:
tags:
- Skills
summary: "POST skills install"
x-loopback-only: true
responses:
"200":
description: OK

View File

@@ -530,7 +530,12 @@ OpenAI-compatible files endpoint for batch input/output and file-purpose uploads
| DELETE | `/v1/files/[id]` | Delete a file |
| GET | `/v1/files/[id]/content` | Stream the raw file body back |
**Auth:** Bearer API key — files are scoped per-API-key via `getApiKeyRequestScope`.
**Auth:** Bearer API key — files are scoped per-API-key via `getApiKeyRequestScope`. A key
sees, downloads and deletes its own files only; a dashboard session without a key reads the
whole instance; a file with no owner (anonymous or dashboard-session upload) is denied to every
non-session caller. `GET /v1/files` rejects an anonymous caller — and a presented key that does
not resolve — with `401` even when `REQUIRE_API_KEY=false`, instead of listing every tenant's
files (GHSA-m3hp-hq9g-fpmv, GHSA-2jm2-mpx8-6523).
---
@@ -546,7 +551,10 @@ OpenAI-compatible batch processing.
| DELETE | `/v1/batches/[id]` | Delete a finished/failed batch |
| POST | `/v1/batches/[id]/cancel` | Cancel an in-progress batch |
**Auth:** Bearer API key. Batches are scoped per-API-key.
**Auth:** Bearer API key. Batches are scoped per-API-key under the same three-way rule as
files: own key only, dashboard session instance-wide, null-owner records denied to every
non-session caller (retrieve, delete, cancel, and the `input_file_id` check on create).
`GET /v1/batches` rejects an anonymous caller with `401` even when `REQUIRE_API_KEY=false`.
---

View File

@@ -46,7 +46,7 @@ A boolean flag is considered **enabled** when its effective value is `"true"`,
## Flag Catalog
55 flags across 6 categories. **Default** is the definition default — the value
60 flags across 6 categories. **Default** is the definition default — the value
used when neither a DB override nor an environment variable is present.
### Security (10)
@@ -88,7 +88,7 @@ used when neither a DB override nor an environment variable is present.
| `CAPABILITY_FILTER_ENABLED` | boolean | `false` | Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter. |
| `RADAR_ENABLED` | boolean | `false` | Enable the OmniRoute Radar module (catalog feed screens and sync). Off by default; enabling only unlocks the UI — data sync remains a separate opt-in. |
### Runtime (23)
### Runtime (28)
| Key | Type | Default | Restart | Description |
| ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -105,6 +105,7 @@ used when neither a DB override nor an environment variable is present.
| `OMNIROUTE_EMERGENCY_FALLBACK` | boolean | `true` | | Route budget-exhausted requests to the emergency free fallback provider/model. (See [Emergency Budget Fallback](#emergency-budget-fallback) below.) |
| `STREAM_RECOVERY_ENABLED` | boolean | `false` | | Enable transparent early retry for truncated upstream SSE streams before any response bytes reach the client. |
| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | boolean | `false` | | Allow stream recovery to re-request and stitch a response after bytes have already reached the client. |
| `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` | boolean | `false` | | Make mid-stream continuation tool-call safe: never resume a cut stream once a tool call was emitted (in flight or already finished with finish_reason tool_calls), and close after one empty continuation instead of spending the whole budget. Off: release behavior. |
| `MODEL_CATALOG_INCLUDE_NAMES` | boolean | `true` | | Include display-friendly name fields in `/v1/models` responses. Disable for clients that expect model IDs only. |
| `MODELS_CATALOG_PREFIX_MODE` | enum | `dual` | | Controls how model IDs are prefixed in /v1/models. 'dual' (default) emits both alias and canonical provider-id prefixes for backward compatibility. 'alias' emits only the short alias prefix (e.g. ds-web/model, not deepseek-web/model). 'canonical' emits only the full provider-id prefix. Values: `dual`, `alias`, `canonical`. |
| `ARENA_ELO_SYNC_ENABLED` | boolean | `true` | | Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings. |
@@ -115,6 +116,10 @@ used when neither a DB override nor an environment variable is present.
| `EXPOSE_FUNCTIONAL_GATEWAY_MIRRORS` | boolean | `false` | | Advertise <gateway-alias>/<model> mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally. |
| `NEWAPI_AGGREGATOR_BALANCE` | boolean | `false` | | Enable balance detection for New-API / One-API / Sub2API aggregator compatible nodes. When enabled, compatible nodes with the aggregator flag set will report their balance in the dashboard and quota-preflight routing. |
| `SERVER_OWNED_TOOL_LOOP_ENABLED` | boolean | `false` | | Continue non-streaming server-owned tool calls until the model returns a client-usable response. |
| `SEARCH_STATS_HIDE_DELETED_CONNECTIONS` | boolean | `false` | | Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id. |
| `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` | boolean | `false` | | Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule. |
| `RETRY_AFTER_PROVENANCE_ENABLED` | boolean | `false` | | On aggregated 429/503 unavailable responses, omit `Retry-After` when no concrete future retry time is known (instead of a synthetic 1s), add `error.retry_after_provenance` (`signal` \| `none`), and let combo drain paths read prose retry hints from JSON and plain-text upstream bodies. The field only appears on responses built by `unavailableResponse()`; other 429/503 bodies are unchanged. |
| `PROTECTED_PRIORITY_INFRA_502_ENABLED` | boolean | `false` | | When a `priority` combo target marked fallback-only-on-quota-exhaustion stops the combo for a cause that is provably not quota (provider circuit breaker open, predictive latency skip), answer 502 instead of the quota-looking 503. Lockout, cooldown, unavailable, exhaustion and concurrency-cap stops keep 503. |
### CLI (5)
@@ -195,7 +200,7 @@ Returns every flag with its effective value, source, and a summary.
"requiresRestart": false,
"warningLevel": "caution",
},
// ... all 55 flags
// ... all 60 flags
],
"summary": {
"total": 54,

View File

@@ -39,41 +39,44 @@ spawn-capable route: a leaked token over a tunnel still can't reach the spawn.
`check-route-guard-membership` gate enumerates every `route.ts` under the
spawn-capable prefixes and fails CI if any is not classified local-only.
| Prefix / pattern | Why it's local-only |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code |
| `/api/cli-tools/{omp,letta,grok-build,forge,jcode,qwen}-settings` | Per-tool settings writers that can touch tool binaries/config on the host |
| `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy control (spawns/points system proxy) |
| `/api/modality-bridge/video/` | Strict trusted-loopback Video Bridge runtime probe and internal extraction bridge |
| `/api/services/` | Embedded services (9Router / CLIProxy / Bifrost / Mux / Dario) — `npm install` + spawn |
| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs |
| `/api/tunnels/cloudflared` | Installs/spawns the cloudflared binary |
| `/api/tunnels/tailscale/{install,enable,disable,login,start-daemon}` | Installs/controls tailscaled on the host |
| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default |
| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits |
| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy |
| `/api/settings/mitm` | Enables MITM interception (system-level proxy state) |
| `/api/issue-agent/` | Issue agent — spawns local tooling against the repo |
| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` |
| `/api/middleware/` | User middleware — loads/executes operator code in-process |
| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` |
| `/api/db-backups/exportAll` | Spawns `tar` for the export archive |
| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker |
| `/api/headroom/start`, `/api/headroom/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID |
| `/api/jobs`, `/api/jobs/` | Job runner control — executes scheduled host-side work |
| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds |
| `/api/oauth/kiro/auto-import` | Reads Kiro CLI credential files from the host |
| `/api/skills/collect/` | Skill collection — detects/installs local tooling |
| `/api/discovery/` | Local network/provider discovery probes |
| `/api/vnc-session` (`VNC_ROUTE_PREFIX`) | Spawns a headful browser + VNC session for interactive logins |
| `/api/acp/agents` | ACP — discovers and spawns local CLI agent binaries |
| `/api/resilience/connections`, `/dashboard/resilience/connections` | Connection maintenance actions that can touch local CLI state |
| `/api/providers/cursor/agent-availability` | Dashboard install-nudge check — spawns `cursor-agent status --format json` |
| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login |
| `/api/providers/volcengine-plan/connect` (regex) | Manual headful flow + session-based phone/SMS auto-login (spawns Playwright) |
| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` |
| `/api/providers/{id}/chatgpt-web-codex-doctor` (regex) | Diagnoses the local Codex CLI install (spawns the binary) |
| Prefix / pattern | Why it's local-only |
| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code |
| `/api/cli-tools/{omp,letta,grok-build,forge,jcode,qwen}-settings` | Per-tool settings writers that can touch tool binaries/config on the host |
| `/api/cli-tools/{claude,cline,codewhale,codex,crush,deepseek-tui,droid,kilo,openclaw,pi,smelt}-settings` | Same `getCliRuntimeStatus()` spawn as the six siblings above (GHSA-35fw-cv32-2373) |
| `/api/cli-tools/{all-statuses,status,detect}` | CLI inventory probes — spawn `command -v` / `--version` per tool (GHSA-35fw-cv32-2373) |
| `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy control (spawns/points system proxy) |
| `/api/modality-bridge/video/` | Strict trusted-loopback Video Bridge runtime probe and internal extraction bridge |
| `/api/services/` | Embedded services (9Router / CLIProxy / Bifrost / Mux / Dario) — `npm install` + spawn |
| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs |
| `/api/tunnels/cloudflared` | Installs/spawns the cloudflared binary |
| `/api/tunnels/tailscale/{install,enable,disable,login,start-daemon}` | Installs/controls tailscaled on the host |
| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default |
| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits |
| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy |
| `/api/settings/mitm` | Enables MITM interception (system-level proxy state) |
| `/api/issue-agent/` | Issue agent — spawns local tooling against the repo |
| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` |
| `/api/middleware/` | User middleware — loads/executes operator code in-process |
| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` |
| `/api/db-backups/exportAll` | Spawns `tar` for the export archive |
| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker |
| `/api/headroom/start`, `/api/headroom/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID |
| `/api/jobs`, `/api/jobs/` | Job runner control — executes scheduled host-side work |
| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds |
| `/api/oauth/kiro/auto-import` | Reads Kiro CLI credential files from the host |
| `/api/skills/collect/` | Skill collection — detects/installs local tooling |
| `/api/skills/install`, `/api/skills/executions` | Skill handler registration + execution — reach the sandbox container spawn (GHSA-jx89) |
| `/api/discovery/` | Local network/provider discovery probes |
| `/api/vnc-session` (`VNC_ROUTE_PREFIX`) | Spawns a headful browser + VNC session for interactive logins |
| `/api/acp/agents` | ACP — discovers and spawns local CLI agent binaries |
| `/api/resilience/connections`, `/dashboard/resilience/connections` | Connection maintenance actions that can touch local CLI state |
| `/api/providers/cursor/agent-availability` | Dashboard install-nudge check — spawns `cursor-agent status --format json` |
| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login |
| `/api/providers/volcengine-plan/connect` (regex) | Manual headful flow + session-based phone/SMS auto-login (spawns Playwright) |
| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` |
| `/api/providers/{id}/chatgpt-web-codex-doctor` (regex) | Diagnoses the local Codex CLI install (spawns the binary) |
**Response on violation:** `403 LOCAL_ONLY`

View File

@@ -226,6 +226,78 @@ export const opencode_goProvider: RegistryEntry = {
supportsVideo: true,
targetFormat: "openai-responses",
},
// #12674: Muse Spark 1.3 Contributor — base + effort-tier aliases from the
// OpenCode Go registry (`opencode models opencode-go --refresh --verbose`;
// exact suffix set: minimal/low/medium/high/xhigh, no max — same as 1.2).
// Upstream serves Muse Spark only on the Responses API; without
// targetFormat:"openai-responses" these fall through to /chat/completions
// and the upstream returns 500 (same class as #12196).
{
id: "muse-spark-1.3-contributor",
name: "Muse Spark 1.3 Contributor",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.3-contributor-minimal",
name: "Muse Spark 1.3 Contributor (minimal effort)",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.3-contributor-low",
name: "Muse Spark 1.3 Contributor (low effort)",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.3-contributor-medium",
name: "Muse Spark 1.3 Contributor (medium effort)",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.3-contributor-high",
name: "Muse Spark 1.3 Contributor (high effort)",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.3-contributor-xhigh",
name: "Muse Spark 1.3 Contributor (xhigh effort)",
contextLength: 1048576,
maxOutputTokens: 131072,
supportsReasoning: true,
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
// #8353: Grok 4.5 + effort tiers from the OpenCode Go registry.
{
id: "grok-4.5",

View File

@@ -50,6 +50,25 @@ export const opencodeProvider: RegistryEntry = {
contextLength: 1048576,
maxOutputTokens: 131072,
},
// Muse Spark 1.3 is served only on the Responses API, same as 1.2 above.
// Its window matches the published OpenCode catalog instead of the
// 200000 provider default.
{
id: "muse-spark-1.3",
name: "Muse Spark 1.3",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{
id: "muse-spark-1.3-contributor-free",
name: "Muse Spark 1.3 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true },
// #6998: 2026-07-14 refresh — the upstream free tier rotated its lineup;
// minimax-m3-free, minimax-m2.5-free, ling-2.6-1t-free,

View File

@@ -85,6 +85,25 @@ export const opencode_zenProvider: RegistryEntry = {
contextLength: 1048576,
maxOutputTokens: 131072,
},
// Muse Spark 1.3 is served only on the Responses API, same as 1.2 above.
// Its window matches the published OpenCode catalog instead of the
// 200000 provider default.
{
id: "muse-spark-1.3",
name: "Muse Spark 1.3",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
{
id: "muse-spark-1.3-contributor-free",
name: "Muse Spark 1.3 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
contextLength: 1048576,
maxOutputTokens: 131072,
},
// ── DeepSeek ────────────────────────────────────────────────
// #10788: same tier vocabulary as opencode-go's DeepSeek rows — the Zen

View File

@@ -94,6 +94,8 @@ const OPENCODE_FREE_MODELS = new Set([
* grok-4.5 low/medium/high; hy3 none/low/high; kimi-k3 max;
* qwen3.6-plus / qwen3.7-max / qwen3.7-plus high/max;
* muse-spark-1.2-contributor minimal/low/medium/high/xhigh (no max)
* - #12674 Muse Spark 1.3 Contributor: minimal/low/medium/high/xhigh (no max),
* verified via `opencode models opencode-go --refresh --verbose`
*/
const EFFORT_TIERS: Record<string, readonly string[]> = {
"deepseek-v4-pro": EFFORT_LEVELS,
@@ -107,6 +109,7 @@ const EFFORT_TIERS: Record<string, readonly string[]> = {
"qwen3.7-max": ["high", "max"],
"qwen3.7-plus": ["high", "max"],
"muse-spark-1.2-contributor": ["minimal", "low", "medium", "high", "xhigh"],
"muse-spark-1.3-contributor": ["minimal", "low", "medium", "high", "xhigh"],
};
/**

View File

@@ -229,6 +229,7 @@ import {
} from "../config/constants.ts";
import { applyStatusRestatement } from "../config/upstreamStatusRestatement.ts";
import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts";
import { buildContinuationLogHooks } from "./chatCore/recoveryTraceLogging.ts";
import {
resolveResilienceSettings,
isStreamRecoveryExplicitlyConfigured,
@@ -3404,11 +3405,7 @@ export async function handleChatCore({
}`
),
continueStream,
onContinue: (attempt) =>
log?.warn?.(
"STREAM_RECOVERY",
`mid-stream continuation attempt ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX}`
),
...buildContinuationLogHooks(log),
throughputWatchdog,
onWatchdogAbort: () =>
log?.warn?.(

View File

@@ -20,6 +20,7 @@ import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"
import { FORMATS } from "../../translator/formats.ts";
import { takeEarlyKeepaliveBytes } from "../../utils/earlyKeepaliveByteBuffer.ts";
import { sanitizeErrorMessage } from "../../utils/error.ts";
import { isEstimatedUsage } from "../../utils/usageTracking.ts";
import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts";
import { attachLogMeta } from "./cacheUsageMeta.ts";
@@ -493,6 +494,9 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
}
: null,
claudePromptCacheUsage: claudeCacheUsageMeta,
// Operators can tell estimated token counts (and the cost derived from them)
// apart from provider-reported ones. Log-only: billing is unchanged.
usageEstimated: isEstimatedUsage(tokens) ? true : null,
})
),
error: error || null,

View File

@@ -21,9 +21,8 @@ export async function scheduleQuotaShareConsumption(args: {
}): Promise<void> {
if (!args.apiKeyId || !args.connectionId) return;
try {
const { scheduleRecordConsumption, buildConsumptionCost } = await import(
"@/lib/quota/spendRecorder"
);
const { scheduleRecordConsumption, buildConsumptionCost } =
await import("@/lib/quota/spendRecorder");
scheduleRecordConsumption(
{
apiKeyId: args.apiKeyId,

View File

@@ -0,0 +1,59 @@
/**
* Log wiring for mid-stream continuation (stream recovery). Kept out of chatCore so the
* call site stays one line.
*
* Levels: the continuation attempt line keeps its release wording at warn; a recovery that
* gives up (the continuation budget is spent, or the continuation request returned no
* stream) is warn; every other outcome — stitched suffix, overlap rejection, terminal or
* empty continuation, a cut refused because of a tool call — is debug, so a healthy stream
* never adds a warn line. Every line carries `attempt N/MAX` so it joins the attempt line.
*/
import { STREAM_RECOVERY } from "../../config/constants.ts";
import type {
ContinuationOutcome,
RecoverableStreamOptions,
} from "../../services/streamRecovery.ts";
type RecoveryLogger =
| {
warn?: (tag: string, message: string) => void;
debug?: (tag: string, message: string) => void;
}
| null
| undefined;
const TAG = "STREAM_RECOVERY";
const MAX = STREAM_RECOVERY.EARLY_RETRY_MAX;
export function formatContinuationOutcome(event: ContinuationOutcome): string {
const head = `mid-stream continuation attempt ${event.attempt}/${MAX} outcome=${event.outcome}`;
switch (event.outcome) {
case "suffix":
return `${head} suffixChars=${event.suffixChars}`;
case "overlap-reject":
return `${head} overlapChars=${event.overlapChars}`;
case "refused":
return `${head} reason=${event.reason}`;
default:
return head;
}
}
/** True for the outcomes that end a recovery without delivering the missing text. */
export function isContinuationGiveUp(event: ContinuationOutcome): boolean {
if (event.outcome === "no-stream") return true;
return event.outcome === "refused" && event.reason === "budget" && event.attempt > 0;
}
export function buildContinuationLogHooks(
log: RecoveryLogger
): Pick<RecoverableStreamOptions, "onContinue" | "onContinueOutcome"> {
return {
onContinue: (attempt) => log?.warn?.(TAG, `mid-stream continuation attempt ${attempt}/${MAX}`),
onContinueOutcome: (event) => {
const line = formatContinuationOutcome(event);
if (isContinuationGiveUp(event)) log?.warn?.(TAG, line);
else log?.debug?.(TAG, line);
},
};
}

View File

@@ -2243,7 +2243,15 @@ async function resolveImageSource(source) {
}
if (isHttpUrl(trimmed)) {
const remoteImage = await fetchRemoteImage(trimmed);
// GHSA-34rg-3pqj-35g9: this URL is caller input (`image_url` / `mask_url` / message
// parts) — pin `public-only` explicitly (string check + DNS validation of every
// resolved answer). Never let it fall back to the operator outbound policy
// (`getProviderOutboundGuard()`), which is `block-metadata` on a local-first default
// install and would let a request body make the server fetch loopback/LAN URLs and
// forward the bytes upstream. `pinDns` stays off on purpose: this handler's only
// transport is `globalThis.fetch` (no `fetchImpl` seam) and connection pinning
// replaces it with a raw undici fetch — same shape as the AI Horde result download.
const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only" });
return {
buffer: remoteImage.buffer,
base64: remoteImage.buffer.toString("base64"),
@@ -3242,7 +3250,10 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) {
if (urlCandidates.length > 0) {
const firstUrl = urlCandidates[0];
const remoteImage = await fetchRemoteImage(firstUrl);
// GHSA-34rg-3pqj-35g9: upstream-supplied result URL, not an OmniRoute-controlled
// host — pin `public-only` exactly like the AI Horde result download does, never
// the operator outbound policy (see `resolveImageSource` for why `pinDns` is off).
const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only" });
const base64 = remoteImage.buffer.toString("base64");
return [{ b64_json: base64, revised_prompt: body.prompt }];
}

View File

@@ -71,7 +71,9 @@ export function extractUpscaleSourceImage(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const providerOptions =
b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options)
b.provider_options &&
typeof b.provider_options === "object" &&
!Array.isArray(b.provider_options)
? (b.provider_options as Record<string, unknown>)
: {};
@@ -161,7 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise<Upscale
}
if (/^https?:\/\//i.test(trimmed)) {
const remote = await fetchRemoteImage(trimmed);
// GHSA-34rg-3pqj-35g9: `source` is caller input (14 body aliases, `provider_options.*`,
// message parts) — pin `public-only` explicitly (string check + DNS validation of
// every resolved answer). Never let it fall back to the operator outbound policy
// (`block-metadata` on a local-first default install), which would let a request
// body make the server fetch loopback/LAN URLs and upload the bytes to the upscale
// provider. `pinDns` stays off on purpose: the download transport here is
// `globalThis.fetch` and connection pinning replaces it with a raw undici fetch —
// same shape as the AI Horde result download in `imageGeneration/providers/aihorde.ts`.
const remote = await fetchRemoteImage(trimmed, { guard: "public-only" });
assertSourceBytes(remote.buffer);
// fetchRemoteImage falls back to application/octet-stream; sniff whenever the
// server did not send a usable image/* type so multipart uploads stay correct.
@@ -214,11 +224,7 @@ export function sniffImageMime(buffer: Buffer): string {
*/
export function readImageDimensions(buffer: Buffer): { width: number; height: number } | null {
try {
if (
buffer.length >= 24 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer.toString("ascii", 1, 4) === "PNG") {
// IHDR is always the first chunk: 8-byte signature + 4 length + 4 "IHDR".
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
@@ -308,10 +314,7 @@ export function scaleDimensions(
const source = readImageDimensions(buffer);
if (!source || source.width <= 0 || source.height <= 0) return null;
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 2;
const scale = Math.min(
safeFactor,
maxEdge / Math.max(source.width, source.height)
);
const scale = Math.min(safeFactor, maxEdge / Math.max(source.width, source.height));
return {
width: Math.max(1, Math.round(source.width * Math.max(1, scale))),
height: Math.max(1, Math.round(source.height * Math.max(1, scale))),
@@ -365,9 +368,7 @@ export function saveUpscaleErrorResult(opts: {
provider: opts.provider,
duration: Date.now() - opts.startTime,
error:
typeof opts.error === "string"
? opts.error.slice(0, 500)
: String(opts.error).slice(0, 500),
typeof opts.error === "string" ? opts.error.slice(0, 500) : String(opts.error).slice(0, 500),
requestBody: opts.requestBody ?? null,
}).catch(() => {});

View File

@@ -2,6 +2,8 @@
* Extract usage from non-streaming response body
* Handles different provider response formats
*/
import { carryEstimatedUsageMarker } from "../utils/usageTracking.ts";
export function extractUsageFromResponse(responseBody, provider) {
if (!responseBody || typeof responseBody !== "object") return null;
const providerId = typeof provider === "string" ? provider.toLowerCase() : "";
@@ -23,7 +25,7 @@ export function extractUsageFromResponse(responseBody, provider) {
responseBody.usage.prompt_tokens_details?.cache_write_tokens ??
responseBody.usage.input_tokens_details?.cache_write_tokens ??
responseBody.usage.cache_write_tokens;
return {
const openAiUsage = {
prompt_tokens: responseBody.usage.prompt_tokens || 0,
completion_tokens: responseBody.usage.completion_tokens || 0,
// DeepSeek native API uses flat prompt_cache_hit_tokens (NOT
@@ -60,6 +62,7 @@ export function extractUsageFromResponse(responseBody, provider) {
? { cost_in_usd_ticks: responseBody.usage.cost_in_usd_ticks }
: {}),
};
return carryEstimatedUsageMarker(responseBody.usage, openAiUsage);
}
// Claude format

View File

@@ -50,7 +50,10 @@ import {
} from "../../src/shared/constants/providers";
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
import { getCodexModelScope } from "../config/codexQuotaScopes.ts";
import { getQuotaScopedModelForProvider, isAntigravityQuotaProvider } from "./antigravityQuotaFamily.ts";
import {
getQuotaScopedModelForProvider,
isAntigravityQuotaProvider,
} from "./antigravityQuotaFamily.ts";
import { persistAntigravityFamilyCooldownIfQuota } from "./antigravityFamilyCooldown.ts";
import {
classifyGeminiQuotaMetricFromText,
@@ -66,12 +69,13 @@ import {
MAX_SHORT_RETRY_HINT_MS,
} from "./retryAfterJson.ts";
import { isMoonshotAccountBalanceExhausted } from "./usage/moonshotOpenPlatform.ts";
import { isTpdRateLimit, resolveTpdCooldownMs } from "./dailyQuotaReset.ts";
import { isTpdRateLimit, resolveTpdCooldownMs, nextConfiguredResetMs } from "./dailyQuotaReset.ts";
// Pre-compiled regex constants for hot-path retry parsing (avoid per-call compilation)
const RETRY_AFTER_RE = /retry\s+after\s+(\d+)\s*s/i;
const PLEASE_RETRY_RE = /please retry in\s+([\d.]+\s*s)/i;
const ISO_RETRY_RE = /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i;
const ISO_RETRY_RE =
/\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i;
const RESETS_AFTER_RE = /resets? after (\d+h)?(\d+m)?(\d+s)?/i;
const WILL_RESET_AFTER_RE = /will reset after (\d+h)?(\d+m)?(\d+s)?/i;
const RESETS_IN_RE = /resets? in (\d+h)?(\d+m)?(\d+s)?/i;
@@ -376,7 +380,8 @@ export const MODEL_ACCESS_DENIED_PATTERNS = [
/\bunsupported\s+model\b/i,
/\baccess.*denied.*model\b/i,
/\bmodel.*access.*denied\b/i,
/\bplease select a different model\b/i, /\bunknown\s+provider\s+for\s+model\b/i,
/\bplease select a different model\b/i,
/\bunknown\s+provider\s+for\s+model\b/i,
// "...access to the requested model" / "model ... access" — bounded lookahead
// (no nested quantifiers) so it stays ReDoS-safe while requiring BOTH an
// access/permission word and "model" so a pure auth error never matches.
@@ -416,7 +421,8 @@ const PROVIDER_MODEL_UNSUPPORTED_PATTERNS = [
/\bmodel\b[\s\S]{0,80}?\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b/i,
/\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b[\s\S]{0,80}?\bmodel\b/i,
/\bunsupported\s+model\b/i,
/\bplease select a different model\b/i, /\bunknown\s+provider\s+for\s+model\b/i,
/\bplease select a different model\b/i,
/\bunknown\s+provider\s+for\s+model\b/i,
];
/**
@@ -656,7 +662,13 @@ export async function recordCoreOwnedAntigravityQuotaState({
}
);
if (lockout.cooldownMs > 0 && isProviderExhaustedReason(fallback)) {
persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason: "quota_exhausted" });
persistAntigravityFamilyCooldownIfQuota({
provider,
connectionId,
model,
cooldownMs: lockout.cooldownMs,
reason: "quota_exhausted",
});
}
return { cooldownMs: lockout.cooldownMs, failureCount: lockout.failureCount };
}
@@ -1661,7 +1673,7 @@ export function checkFallbackError(
timezone?: unknown;
hour?: unknown;
nowMs?: number;
} | null,
} | null
): {
shouldFallback: boolean;
cooldownMs: number;
@@ -1987,7 +1999,7 @@ export function checkFallbackError(
// no clock, no header — short 429, do not guess midnight
console.warn(
"[accountFallback] TPD 429 without node daily-reset clock or Reset header; using short cooldown",
{ provider },
{ provider }
);
} else {
return {
@@ -1998,7 +2010,13 @@ export function checkFallbackError(
};
}
} else {
const msUntilTomorrow = getMsUntilTomorrow();
// Operator node clock first; host-midnight estimate when unconfigured.
const tzMs = nextConfiguredResetMs(
dailyReset?.timezone,
dailyReset?.hour,
dailyReset?.nowMs ?? Date.now()
);
const msUntilTomorrow = tzMs ?? getMsUntilTomorrow();
// Cap at 24 hours to handle timezone edge cases
const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000);
return {
@@ -2434,7 +2452,13 @@ export function applyErrorState<T extends AccountState | null | undefined>(
// (`markConnectionQuotaExhausted`) so a DB failure can never crash the
// chat path. See issue #1 (per-account 429 cascade not persisting).
const connId = (account as AccountState | null | undefined)?.id;
if (typeof connId === "string" && connId.length > 0 && effectiveCooldownMs > 0 && nextState.rateLimitedUntil && !isAntigravityQuotaProvider(prov)) {
if (
typeof connId === "string" &&
connId.length > 0 &&
effectiveCooldownMs > 0 &&
nextState.rateLimitedUntil &&
!isAntigravityQuotaProvider(prov)
) {
try {
const untilMs = cooldownUntilMs(nextState.rateLimitedUntil);
if (Number.isFinite(untilMs) && untilMs > Date.now()) {

View File

@@ -20,11 +20,7 @@ import {
import { getHiddenModelsByProvider } from "@/models";
import {
evaluateQuotaCutoff,
getQuotaFetcher,
type QuotaInfo,
} from "./quotaPreflight.ts";
import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
import { resolveProviderId } from "../../src/shared/constants/providers.ts";
import { getQuotaFetchScope } from "./antigravityQuotaFamily.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
@@ -37,10 +33,7 @@ import { projectAccountTier, type ProviderCandidate } from "./autoCombo/scoring.
import { getSessionConnection } from "./sessionManager.ts";
import { getOAuthSessionAvailability } from "./oauthSessionOccupancy.ts";
import {
clearStickyBinding,
peekStickyConnectionId,
} from "./combo/sessionStickiness.ts";
import { clearStickyBinding, peekStickyConnectionId } from "./combo/sessionStickiness.ts";
import { lookupPositiveCap } from "./combo/concurrencyCaps.ts";
import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts";
@@ -107,20 +100,13 @@ import {
tryPipelineDispatch,
tryRuntimeUnitDispatch,
} from "./combo/dispatchPrelude.ts";
import {
resolveShadowTargets,
scheduleShadowRouting,
} from "./combo/shadowRouting.ts";
import { resolveShadowTargets, scheduleShadowRouting } from "./combo/shadowRouting.ts";
import {
filterTargetsByRequestCompatibility,
resolveComboRuntimeUnits,
resolveComboTargets,
} from "./combo/comboStructure.ts";
import {
createInvocationId,
getComboTrace,
startComboTrace,
} from "./combo/decisionTrace.ts";
import { createInvocationId, getComboTrace, startComboTrace } from "./combo/decisionTrace.ts";
import {
QUOTA_SOFT_DEPRIORITIZE_FACTOR,
setCandidateQuotaSoftPenalty,
@@ -135,20 +121,15 @@ import {
calculateResetWindowAffinity,
type ResetWindowConfig,
} from "./combo/quotaScoring.ts";
import {
fetchResetAwareQuotaWithCache,
preScreenTargets,
} from "./combo/quotaStrategies.ts";
import { fetchResetAwareQuotaWithCache, preScreenTargets } from "./combo/quotaStrategies.ts";
import { buildAutoQuotaThresholds } from "./combo/quotaExhaustionCutoff.ts";
import { expandTargetsByFingerprints } from "./combo/fingerprintExpansion.ts";
import { resolveComboTargetPipeline } from "./combo/targetResolution.ts";
import { dispatchWithCooldownRetry } from "./combo/comboAttemptLoop.ts";
import { evaluateExecuteTargetGates } from "./combo/executeTargetGates.ts";
import { executeTargetAttempt } from "./combo/executeTargetAttempt.ts";
import type {
AttemptLoopDeps,
AttemptLoopState,
} from "./combo/attemptLoopTypes.ts";
import type { AttemptLoopDeps, AttemptLoopState } from "./combo/attemptLoopTypes.ts";
import { clearStaleLKGP } from "./combo/staleLkgpClear.ts";
export { RESET_WINDOW_NAMES, QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty };
export { scoreAutoTargets, expandAutoComboCandidatePool };
@@ -195,32 +176,8 @@ export function releaseStickyPinOnFailure(
clearStickyBinding(messageHash);
}
/**
* Clear persisted LKGP pins when a target fails or is skipped due to
* exhaustion, cooldown, or unavailability (#11911 #919).
*/
export function clearStaleLKGP(
comboName: string,
executionKey?: string | null,
comboId?: string | null,
log?: { warn?: (tag: string, msg: string, data?: unknown) => void } | null,
tag: string = "COMBO"
): void {
void (async () => {
try {
const { clearLKGP } = await import("@/lib/db/settings");
const promises: Promise<void>[] = [clearLKGP(comboName, comboId || comboName)];
if (executionKey) {
promises.push(clearLKGP(comboName, executionKey));
}
await Promise.all(promises);
} catch (err) {
log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
}
// #11911 #919: non-blocking stale-pin clear whose failures log with combo context.
export { clearStaleLKGP };
const DEFAULT_MODEL_P95_MS: Record<string, number> = {
"grok-4-fast-non-reasoning": 1143,
@@ -1081,4 +1038,3 @@ async function handleComboChatInner({
_unregisterExecutionCandidates(_registeredExecutionKeys);
}
}

View File

@@ -0,0 +1,34 @@
/**
* Operator daily-reset clock lookup for the combo failure paths.
*
* Combo targets classify upstream failures with `checkFallbackError` directly,
* so they need the same per-provider `{ timezone, hour }` clock that the
* single-model path resolves in `src/sse/services/auth.ts`
* (`resolveDailyResetForProvider`): the provider node matched by id or prefix.
*
* Resolved on every failure through `getCachedProviderNodes`, which already
* owns caching (short TTL, invalidated on every provider_nodes write). There is
* deliberately no second cache here: a timezone/hour edit reaches combos
* without a restart, and a failed lookup returns null (host-midnight fallback in
* `checkFallbackError`) without being remembered.
*
* Dynamic import keeps the combo leaf free of a static edge into the DB layer.
*/
export type ComboDailyResetClock = { timezone?: unknown; hour?: unknown };
export async function resolveComboDailyReset(
provider: string | null | undefined
): Promise<ComboDailyResetClock | null> {
if (!provider || provider === "unknown") return null;
try {
const { getCachedProviderNodes } = await import("@/lib/db/readCache");
const nodes = await getCachedProviderNodes();
const node = nodes.find((n) => n && (n.id === provider || n.prefix === provider));
if (!node) return null;
return { timezone: node.dailyQuotaResetTimezone, hour: node.dailyQuotaResetHour };
} catch {
// no-effect: an unreadable node table falls back to host midnight in checkFallbackError
return null;
}
}

View File

@@ -246,6 +246,7 @@ const REQUEST_SCOPED_UPSTREAM_ERROR_CODES: Record<string, true> = {
rate_limit_queue_timeout: true,
rate_limit_queue_full: true,
rate_limit_queue_wedged: true,
token_limit_exceeded: true,
// #10360: our own executor-result contract violation. An internal defect, not
// a provider/account fault — it must never cool a connection or trip a breaker.
[EXECUTOR_CONTRACT_VIOLATION_CODE]: true,

View File

@@ -18,7 +18,12 @@ import {
retryHintBypassesMaxCooldownMs,
selectLockoutCooldownMs,
} from "../accountFallback.ts";
import { errorResponse, errorResponseWithComboDiagnostics } from "../../utils/error.ts";
import {
errorResponse,
errorResponseWithComboDiagnostics,
logRetryHintUnreadable,
readProseRetryAfter,
} from "../../utils/error.ts";
import { recordComboFailure, clearComboFailureTracking } from "./failureTracker.ts";
import { buildRecoveryHint } from "./pinRecovery.ts";
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
@@ -91,6 +96,9 @@ import type { AttemptLoopDeps, AttemptLoopState, ExecuteTargetResult } from "./a
import type { ComboDiagnostics } from "../../utils/error.ts";
import type { ComboErrorBody, ComboRetryAfter, ResolvedComboTarget } from "./types.ts";
import type { ResponseValidationConfig } from "./responseValidation.ts";
import { resolveComboDailyReset } from "./comboDailyResetClock.ts";
import { protectedPriorityStopStatus } from "./protectedPriorityStopStatus.ts";
import type { ProtectedPriorityStopCause } from "./protectedPriorityStopStatus.ts";
export async function executeTargetAttempt(opts: {
index: number;
@@ -114,11 +122,11 @@ export async function executeTargetAttempt(opts: {
const fallbackDelayMs = resolveDelayMs(deps.config.fallbackDelayMs, 0);
const universalHandoffConfig = deps.universalHandoffConfig ?? DEFAULT_UNIVERSAL_HANDOFF_CONFIG;
const stopProtectedPriorityTarget = (message: string) => {
const stopProtectedPriorityTarget = (message: string, cause?: ProtectedPriorityStopCause) => {
state.observeFailure(false, target.executionKey);
deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO");
return protectedPriorityTarget
? { ok: false as const, response: errorResponse(503, message) }
? { ok: false as const, response: errorResponse(protectedPriorityStopStatus(cause), message) }
: null;
};
@@ -194,7 +202,10 @@ export async function executeTargetAttempt(opts: {
decision: "skipped_before_dispatch",
reason: "predictive_ttft",
});
return stopProtectedPriorityTarget(`Predictive latency check rejected ${modelStr}`);
return stopProtectedPriorityTarget(
`Predictive latency check rejected ${modelStr}`,
"predictive_ttft"
);
}
}
}
@@ -667,10 +678,12 @@ export async function executeTargetAttempt(opts: {
let errorText = result.statusText || "";
let errorBody: ComboErrorBody = null;
let retryAfter: ComboRetryAfter | null = null;
let bodyText = "";
try {
const cloned = result.clone();
try {
const text = await cloned.text();
bodyText = text;
if (text) {
errorText = text.substring(0, 500);
errorBody = JSON.parse(text);
@@ -702,11 +715,12 @@ export async function executeTargetAttempt(opts: {
: null);
}
} catch {
/* Clone parse failed */
logRetryHintUnreadable(deps.log, "COMBO", modelStr, result.status, "unparseable body");
}
} catch {
/* Clone failed */
logRetryHintUnreadable(deps.log, "COMBO", modelStr, result.status, "clone failed");
}
retryAfter ||= readProseRetryAfter(bodyText); // #13672 opt-in prose retry hints
// Track earliest retryAfter
if (
@@ -835,7 +849,9 @@ export async function executeTargetAttempt(opts: {
provider,
result.headers,
profile,
structuredError
structuredError,
null,
await resolveComboDailyReset(provider)
);
const { cooldownMs } = fallbackResult;
// #6863: a parsed upstream quota reset (e.g. Antigravity "Resets in 92h27m28s")

View File

@@ -25,6 +25,8 @@ import {
resolvePersistedConnectionCooldownSkipReason,
} from "./comboPredicates.ts";
import { resolveQuotaExhaustionCutoffForTarget } from "./quotaExhaustionCutoff.ts";
import { protectedPriorityStopStatus } from "./protectedPriorityStopStatus.ts";
import type { ProtectedPriorityStopCause } from "./protectedPriorityStopStatus.ts";
import type { AttemptLoopDeps, AttemptLoopState, GateDecision } from "./attemptLoopTypes.ts";
import type { ResolvedComboTarget } from "./types.ts";
@@ -58,11 +60,11 @@ export async function evaluateExecuteTargetGates(opts: {
const protectedPriorityTarget =
deps.strategy === "priority" && target.fallbackOnlyOnQuotaExhaustion === true;
const stopProtectedPriorityTarget = (message: string) => {
const stopProtectedPriorityTarget = (message: string, cause?: ProtectedPriorityStopCause) => {
state.observeFailure(false, target.executionKey);
deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO");
return protectedPriorityTarget
? { ok: false as const, response: errorResponse(503, message) }
? { ok: false as const, response: errorResponse(protectedPriorityStopStatus(cause), message) }
: null;
};
@@ -93,7 +95,10 @@ export async function evaluateExecuteTargetGates(opts: {
bumpFallback();
return {
kind: "skip",
result: stopProtectedPriorityTarget(`Provider ${provider} circuit breaker is open`),
result: stopProtectedPriorityTarget(
`Provider ${provider} circuit breaker is open`,
"circuit_open"
),
};
}

View File

@@ -0,0 +1,31 @@
/**
* #13439 — HTTP status for a protected-priority stop: a `priority` target marked
* `fallbackOnlyOnQuotaExhaustion` stops the combo instead of falling through, and
* every such stop answers 503, which reads like quota exhaustion.
*
* Only causes that are provably NOT quota, rate limit or cooldown may answer 502:
* - `circuit_open`: the whole-provider breaker opens on 408/5xx only
* (PROVIDER_BREAKER_FAILURE_STATUSES; 429 and request-scoped failures never trip it);
* - `predictive_ttft`: skipped on recorded latency alone.
* Everything else (model lockout, provider/connection cooldown, request exhaustion,
* unavailable credentials, credential gate, concurrency cap, quota cutoff) keeps 503:
* those cannot be proven non-quota.
*
* Opt-in via PROTECTED_PRIORITY_INFRA_502_ENABLED (default off) because it changes a
* client-visible status; a flag-read failure keeps 503.
*
* @internal — not part of the public combo.ts barrel.
*/
import { isFeatureFlagEnabled } from "../../../src/shared/utils/featureFlags.ts";
export type ProtectedPriorityStopCause = "circuit_open" | "predictive_ttft";
export function protectedPriorityStopStatus(cause?: ProtectedPriorityStopCause): 502 | 503 {
if (cause !== "circuit_open" && cause !== "predictive_ttft") return 503;
try {
return isFeatureFlagEnabled("PROTECTED_PRIORITY_INFRA_502_ENABLED") ? 502 : 503;
} catch {
// no-effect: an unreadable flag store keeps the legacy 503
return 503;
}
}

View File

@@ -12,6 +12,8 @@ import {
errorResponse,
unavailableResponse,
errorResponseWithComboDiagnostics,
logRetryHintUnreadable,
readProseRetryAfter,
} from "../../utils/error.ts";
import { buildRecoveryHint } from "./pinRecovery.ts";
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
@@ -112,6 +114,7 @@ import {
resolveComboTargets,
} from "./comboStructure.ts";
import { releaseStickyPinOnFailure, clearStaleLKGP } from "../combo.ts";
import { resolveComboDailyReset } from "./comboDailyResetClock.ts";
/** Per-connection TPM budget for quota reservation. Undefined = store keeps prior limit. */
async function resolveTargetTokenLimit(target: {
@@ -811,10 +814,12 @@ export async function handleRoundRobinCombo({
let errorText = result.statusText || "";
let retryAfter: ComboRetryAfter | null = null;
let errorBody: ComboErrorBody = null;
let bodyText = "";
try {
const cloned = result.clone();
try {
const text = await cloned.text();
bodyText = text;
if (text) {
errorText = text.substring(0, 500);
errorBody = JSON.parse(text);
@@ -827,11 +832,12 @@ export async function handleRoundRobinCombo({
retryAfter = errorBody?.retryAfter || null;
}
} catch {
/* Clone parse failed */
logRetryHintUnreadable(log, "COMBO-RR", modelStr, result.status, "unparseable body");
}
} catch {
/* Clone failed */
logRetryHintUnreadable(log, "COMBO-RR", modelStr, result.status, "clone failed");
}
retryAfter ||= readProseRetryAfter(bodyText); // #13672 opt-in prose hints
if (result.status === 499) {
log.info(
@@ -921,7 +927,9 @@ export async function handleRoundRobinCombo({
provider,
result.headers,
profile,
structuredError
structuredError,
null,
await resolveComboDailyReset(provider)
);
const { cooldownMs } = fallbackResult;
const selectedConnectionId =

View File

@@ -0,0 +1,44 @@
/**
* Clear persisted LKGP pins when a combo target fails or is skipped for exhaustion,
* cooldown or unavailability (#11911 #919).
*
* Non-blocking by design: the fallback loop never waits on these SQLite writes. A
* failed clear is not silent — it logs a warning carrying the combo and the
* execution key. The returned promise never rejects: routing callers ignore it,
* tests await it.
*
* @internal — re-exported by combo.ts as `clearStaleLKGP`.
*/
type WarnLogger = { warn?: (tag: string, msg: string, data?: unknown) => void } | null;
type ClearLkgp = (comboName: string, modelKey: string) => Promise<void>;
async function clearPins(
comboName: string,
executionKey: string | null | undefined,
comboId: string | null | undefined,
clearLKGP: ClearLkgp | undefined
): Promise<void> {
const clear = clearLKGP ?? (await import("@/lib/db/settings")).clearLKGP;
const keys = [comboId || comboName, ...(executionKey ? [executionKey] : [])];
await Promise.all(keys.map((key) => clear(comboName, key)));
}
export function clearStaleLKGP(
comboName: string,
executionKey?: string | null,
comboId?: string | null,
log?: WarnLogger,
tag: string = "COMBO",
/** Test seam; the routing path always resolves clearLKGP from @/lib/db/settings. */
clearLKGP?: ClearLkgp
): Promise<void> {
return clearPins(comboName, executionKey, comboId, clearLKGP).catch((err: unknown) => {
log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", {
combo: comboName,
comboId: comboId ?? null,
executionKey: executionKey ?? null,
err,
});
});
}

View File

@@ -32,17 +32,30 @@ type ZonedParts = {
second: number;
};
// Formatter construction dominates zonedParts; the DST-gap walk below calls it
// hundreds of times, so reuse one formatter per (validated) IANA zone.
const zonedFormatters = new Map<string, Intl.DateTimeFormat>();
function zonedFormatter(timeZone: string): Intl.DateTimeFormat {
let fmt = zonedFormatters.get(timeZone);
if (!fmt) {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
zonedFormatters.set(timeZone, fmt);
}
return fmt;
}
function zonedParts(ms: number, timeZone: string): ZonedParts {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const fmt = zonedFormatter(timeZone);
const bag: Record<string, string> = {};
for (const part of fmt.formatToParts(new Date(ms))) {
if (part.type !== "literal") bag[part.type] = part.value;
@@ -57,7 +70,11 @@ function zonedParts(ms: number, timeZone: string): ZonedParts {
};
}
function addCalendarDay(year: number, month: number, day: number): {
function addCalendarDay(
year: number,
month: number,
day: number
): {
year: number;
month: number;
day: number;
@@ -67,6 +84,35 @@ function addCalendarDay(year: number, month: number, day: number): {
return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() };
}
/**
* Offset-iteration wall-clock → epoch conversion. `exact` is false when the
* iteration never lands on the wanted wall time, which is what a wall time
* inside a DST gap (a local time that does not exist) does.
*/
function convergeWallTime(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
timeZone: string
): { ms: number; exact: boolean } {
const wanted = Date.UTC(year, month - 1, day, hour, minute, second);
let guess = wanted;
for (let i = 0; i < 4; i++) {
const p = zonedParts(guess, timeZone);
const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
const delta = asIfUtc - wanted;
if (delta === 0) return { ms: guess, exact: true };
guess -= delta;
}
return { ms: guess, exact: false };
}
/** Gap-walk bound: one full day covers every civil gap, including a skipped calendar day. */
const MAX_GAP_WALK_MINUTES = 24 * 60;
/** Convert wall-clock time in `timeZone` to epoch ms. */
function zonedLocalToUtc(
year: number,
@@ -75,18 +121,35 @@ function zonedLocalToUtc(
hour: number,
minute: number,
second: number,
timeZone: string,
timeZone: string
): number {
const wanted = Date.UTC(year, month - 1, day, hour, minute, second);
let guess = wanted;
for (let i = 0; i < 4; i++) {
const p = zonedParts(guess, timeZone);
const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
const delta = asIfUtc - wanted;
if (delta === 0) return guess;
guess -= delta;
const first = convergeWallTime(year, month, day, hour, minute, second, timeZone);
if (first.exact) return first.ms;
// DST gap (New York 02:00 on spring-forward, Havana/Santiago 00:00): the offset
// iteration settles an hour EARLY. Walk the wall clock forward minute by minute to
// the first wall time that exists; gap widths vary (30 min, 1 h), so never add a
// fixed offset.
let date = { year, month, day };
let minuteOfDay = hour * 60 + minute;
for (let step = 0; step < MAX_GAP_WALK_MINUTES; step++) {
minuteOfDay += 1;
if (minuteOfDay >= 24 * 60) {
minuteOfDay -= 24 * 60;
date = addCalendarDay(date.year, date.month, date.day);
}
const h = Math.floor(minuteOfDay / 60);
const candidate = convergeWallTime(
date.year,
date.month,
date.day,
h,
minuteOfDay % 60,
second,
timeZone
);
if (candidate.exact) return candidate.ms;
}
return guess;
return first.ms;
}
/**
@@ -130,7 +193,7 @@ export type TpdCooldownOptions = {
*/
export function resolveTpdCooldownMs(
errorText: string | null | undefined,
options: TpdCooldownOptions = {},
options: TpdCooldownOptions = {}
): number | null {
if (!isTpdRateLimit(errorText)) return null;
const now = options.nowMs ?? Date.now();
@@ -143,3 +206,19 @@ export function resolveTpdCooldownMs(
}
return null;
}
/**
* Milliseconds until the next operator-configured daily reset, or null when
* the clock is absent, invalid, or already passed. Shared by the non-TPD
* daily-quota paths so configured and unconfigured behavior stay in one place.
*/
export function nextConfiguredResetMs(
timezone: unknown,
hour: unknown,
nowMs: number
): number | null {
if (typeof timezone !== "string" || !isValidResetHour(hour)) return null;
if (!nodeDailyResetConfigured(timezone, hour)) return null;
const ms = nextDailyResetAtMs(timezone, hour, nowMs) - nowMs;
return ms > 0 ? ms : null;
}

View File

@@ -88,7 +88,20 @@ export const PROVIDER_ERROR_TYPES = {
// Google account must Bring Its Own GCP Project. Account-specific and
// fixable by entering a Project ID — never a model lockout and never a ban.
GCP_PROJECT_REQUIRED: "gcp_project_required",
};
} as const;
export type ProviderErrorType = (typeof PROVIDER_ERROR_TYPES)[keyof typeof PROVIDER_ERROR_TYPES];
// Versioned vocabulary persisted in `call_logs.error_type`: every provider error
// family plus the explicit `unknown` for a failure the classifier could not place.
// Derived from PROVIDER_ERROR_TYPES so the two cannot drift. Bump the version when
// a value is removed or renamed (adding a family is backwards compatible).
export type ErrorTypeContract = ProviderErrorType | "unknown";
export const ERROR_TYPE_CONTRACT: readonly ErrorTypeContract[] = Object.freeze([
...Object.values(PROVIDER_ERROR_TYPES),
"unknown",
]);
export const ERROR_TYPE_CONTRACT_VERSION = 1;
export const CONTEXT_OVERFLOW_SIGNALS = [
"context overflow",
@@ -248,7 +261,7 @@ export function classifyProviderError(
statusCode: number,
responseBody: unknown,
provider?: string | null
): string | null {
): ProviderErrorType | null {
const bodyStr = responseBodyToString(responseBody);
const creditsExhausted = isCreditsExhausted(bodyStr);
const subscriptionQuotaExhausted = isSubscriptionQuotaText(bodyStr.toLowerCase());
@@ -256,7 +269,10 @@ export function classifyProviderError(
const oauthInvalid = isOAuthInvalidToken(bodyStr);
const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider);
if ((creditsExhausted || subscriptionQuotaExhausted) && [400, 401, 402, 403].includes(statusCode)) {
if (
(creditsExhausted || subscriptionQuotaExhausted) &&
[400, 401, 402, 403].includes(statusCode)
) {
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
}

View File

@@ -24,10 +24,8 @@ import {
type QuotaFetcher,
type QuotaInfo,
} from "./quotaPreflight.ts";
import {
getAntigravityQuotaFamily,
getQuotaFetchScope,
} from "./antigravityQuotaFamily.ts";
import { getAntigravityQuotaFamily, getQuotaFetchScope } from "./antigravityQuotaFamily.ts";
import { boundedMap } from "../../src/lib/quota/boundedMap.ts";
type UsageFetcher = (
connection: Parameters<typeof getUsageForProvider>[0],
@@ -77,29 +75,19 @@ export function __resetGenericQuotaFetcherForTests(): void {
pendingForceRefreshMiss.clear();
}
interface CacheEntry {
quota: QuotaInfo;
fetchedAt: number;
}
const cache = new Map<string, CacheEntry>();
// One entry per (provider, connection); 4096 keeps even very large account pools
// from ever evicting. An evicted entry only costs one extra upstream quota read.
const cache = boundedMap<QuotaInfo>("quota-fetcher-cache", 4096, "ttl", CACHE_TTL_MS);
function connectionKey(provider: string, connectionId: string): string {
return `${provider.trim()}::${connectionId.trim()}`;
}
function quotaCacheScope(
provider: string,
requestedModel?: string | null
): string {
function quotaCacheScope(provider: string, requestedModel?: string | null): string {
return getQuotaFetchScope(provider, requestedModel);
}
function cacheKey(
provider: string,
connectionId: string,
requestedModel?: string | null
): string {
function cacheKey(provider: string, connectionId: string, requestedModel?: string | null): string {
return `${connectionKey(provider, connectionId)}::${quotaCacheScope(provider, requestedModel)}`;
}
@@ -125,22 +113,14 @@ function markPendingForceRefreshMiss(key: string): void {
if (isPendingForceRefresh(key)) pendingForceRefreshMiss.set(key, Date.now());
}
function cachedQuotaIfFresh(
key: string,
forceRefresh: boolean,
now: number
): QuotaInfo | null {
function cachedQuotaIfFresh(key: string, forceRefresh: boolean, now: number): QuotaInfo | null {
if (forceRefresh) return null;
const cached = cache.get(key);
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.quota;
const cached = cache.get(key, now);
if (cached !== undefined) return cached;
return null;
}
function isForceRefreshMissCooling(
key: string,
forceRefresh: boolean,
now: number
): boolean {
function isForceRefreshMissCooling(key: string, forceRefresh: boolean, now: number): boolean {
if (!forceRefresh) return false;
const missedAt = pendingForceRefreshMiss.get(key);
return missedAt !== undefined && now - missedAt < CACHE_TTL_MS;
@@ -150,18 +130,17 @@ function isForceRefreshMissCooling(
function isConcurrentForceRefresh(key: string, refreshStamp: number | undefined): boolean {
const currentStamp = pendingForceRefresh.get(key);
if (currentStamp === refreshStamp) return false;
return (
currentStamp !== undefined &&
Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS
);
return currentStamp !== undefined && Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS;
}
// 5min — same as Codex. Expiry is lazy on read (`isPendingForceRefresh`);
// this timer only reaps keys nobody fetches after the 5min TTL.
// 5min — same TTL as the original reap (CACHE_TTL_MS * 5). Expiry lazy on read
// (boundedMap ttl policy); this timer only keeps the sweep of
// pendingForceRefresh (5-min TTL, no systematic lazy read) + an opportunistic purge
// of stale cache entries along the way (get auto-purges).
const _cacheCleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of cache) {
if (now - entry.fetchedAt > CACHE_TTL_MS * 5) cache.delete(key);
for (const key of cache.keys()) {
cache.get(key);
}
for (const key of pendingForceRefresh.keys()) {
dropExpiredPendingForceRefresh(key, now);
@@ -289,10 +268,7 @@ export function convertUsageToQuotaInfo(
const normalized = normalizeQuotaWindows(providerScopedWindows, context);
const scopedEntries = Object.values(providerScopedWindows);
const percentUsed = scopedEntries.reduce(
(worst, entry) => Math.max(worst, entry.percentUsed),
0
);
const percentUsed = scopedEntries.reduce((worst, entry) => Math.max(worst, entry.percentUsed), 0);
const resetAt =
scopedEntries.reduce<{ percentUsed: number; resetAt: string | null } | null>(
(worst, entry) => (!worst || entry.percentUsed > worst.percentUsed ? entry : worst),
@@ -322,10 +298,7 @@ function isAntigravityProvider(provider: string | null | undefined): boolean {
return provider === "antigravity" || provider === "agy";
}
function antigravityWeeklyWindowMatchesFamily(
key: string,
family: "gemini" | "claude"
): boolean {
function antigravityWeeklyWindowMatchesFamily(key: string, family: "gemini" | "claude"): boolean {
if (!key.endsWith("_weekly")) return false;
return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly";
}
@@ -456,7 +429,7 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection)
const unscopedQuota = convertUsageToQuotaInfo(usage, { provider });
registerQuotaWindows(provider, Object.keys(unscopedQuota?.windows || quota.windows || {}));
cache.set(key, { quota, fetchedAt: Date.now() });
cache.set(key, quota);
return quota;
};

View File

@@ -1,7 +1,10 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { ALIAS_TO_PROVIDER_ID, resolveProviderAlias } from "./providerAlias.ts";
import { resolveWildcardAlias } from "./wildcardRouter.ts";
import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts";
export { resolveProviderAlias };
type ProviderModelAliasMap = Record<string, Record<string, string>>;
type ModelAliasValue = string | { provider?: string; model?: string };
type ModelAliasMap = Record<string, ModelAliasValue>;
@@ -27,38 +30,6 @@ export function stripContextWindowSuffix(
return modelStr.replace(CONTEXT_WINDOW_SUFFIX_RE, "").trimEnd();
}
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Manual alias overrides — maps slug-style prefixes to canonical provider IDs.
// These live outside the registry because they represent multiple providers
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
// and keep backward compatibility when upstream IDs change.
const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = {
@@ -180,31 +151,6 @@ interface ProviderConnectionLike {
is_active?: unknown;
}
/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
// Follow the alias chain transitively so intermediate alias-only hops resolve
// to the final target, but STOP as soon as a hop lands on a registered
// provider id (#2901): "oc" must resolve to the no-auth "opencode" provider,
// NOT continue through the manual "opencode" → "opencode-zen" slug override —
// that override is for user-typed `opencode/` prefixes only. Without this
// boundary the no-auth provider becomes unreachable by any prefix.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (next in PROVIDER_ID_TO_ALIAS) return next;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}
/**
* #474 — Resolve a bare model name to the selected connection's `defaultModel`.
*

View File

@@ -0,0 +1,58 @@
import { PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
export const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Manual alias overrides — maps slug-style prefixes to canonical provider IDs.
// These live outside the registry because they represent multiple providers
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
// Follow the alias chain transitively so intermediate alias-only hops resolve
// to the final target, but STOP as soon as a hop lands on a registered
// provider id (#2901): "oc" must resolve to the no-auth "opencode" provider,
// NOT continue through the manual "opencode" → "opencode-zen" slug override —
// that override is for user-typed `opencode/` prefixes only. Without this
// boundary the no-auth provider becomes unreachable by any prefix.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (next in PROVIDER_ID_TO_ALIAS) return next;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}

View File

@@ -39,6 +39,7 @@ import {
getExecutorTimeoutMs,
resolveConnectionTimeoutMs,
} from "../handlers/chatCore/upstreamTimeouts.ts";
import { boundedMap } from "../../src/lib/quota/boundedMap.ts";
interface LearnedLimitEntry {
provider: string;
@@ -90,8 +91,12 @@ const enabledConnections = new Set<string>();
const connectionRateLimitOverrides = new Map<string, Record<string, number>>();
// Store learned limits for persistence (debounced)
const learnedLimits: Record<string, LearnedLimitEntry> = {};
const MAX_LEARNED_LIMITS = 200;
// One learned entry per limiter key (provider:connection[:model]). The previous
// `MAX_LEARNED_LIMITS = 200` was declared but never enforced; enforcing 200 would
// start evicting (dropping persisted limits) on deployments with many
// connection×model limiters, so the enforced cap is set well above that.
export const MAX_LEARNED_LIMITS = 2048;
const learnedLimits = boundedMap<LearnedLimitEntry>("learned-limits", MAX_LEARNED_LIMITS, "lru");
const limiterLastUsed = new Map<string, number>();
let persistTimer: ReturnType<typeof setTimeout> | null = null;
const pendingAsyncOperations = new Set<Promise<unknown>>();
@@ -969,7 +974,7 @@ export function getAllRateLimitStatus() {
* Get all learned limits (for dashboard display).
*/
export function getLearnedLimits() {
return { ...learnedLimits };
return { ...Object.fromEntries(learnedLimits) };
}
// ─── Persistence ────────────────────────────────────────────────────────────
@@ -977,10 +982,8 @@ export function getLearnedLimits() {
async function persistLearnedLimitsNow() {
try {
const { updateSettings } = await import("@/lib/db/settings");
await updateSettings({ learnedRateLimits: JSON.stringify(learnedLimits) });
logRateLimit(
`💾 [RATE-LIMIT] Persisted learned limits for ${Object.keys(learnedLimits).length} provider(s)`
);
await updateSettings({ learnedRateLimits: JSON.stringify(Object.fromEntries(learnedLimits)) });
logRateLimit(`💾 [RATE-LIMIT] Persisted learned limits for ${learnedLimits.size} provider(s)`);
} catch (err) {
errorRateLimit("[RATE-LIMIT] Failed to persist learned limits:", err.message);
}
@@ -996,12 +999,12 @@ function recordLearnedLimit(
model: string | null = null
) {
const key = getLimiterKey(provider, connectionId, model);
learnedLimits[key] = {
learnedLimits.set(key, {
...limits,
provider,
connectionId,
lastUpdated: Date.now(),
};
});
// Debounce: save at most once per PERSIST_DEBOUNCE_MS
if (!persistTimer) {
@@ -1054,8 +1057,8 @@ export async function __resetRateLimitManagerForTests() {
limiterWatchdog.reset();
shutdownHandlersRegistered = false;
for (const key of Object.keys(learnedLimits)) {
delete learnedLimits[key];
for (const key of [...learnedLimits.keys()]) {
learnedLimits.delete(key);
}
if (pendingAsyncOperations.size > 0) {
@@ -1108,14 +1111,14 @@ async function loadPersistedLimits() {
const remaining = toNumber(data.remaining, 0);
const minTime = toNumber(data.minTime, 0);
learnedLimits[key] = {
learnedLimits.set(key, {
provider,
connectionId,
lastUpdated,
...(limit > 0 ? { limit } : {}),
...(remaining >= 0 ? { remaining } : {}),
...(minTime >= 0 ? { minTime } : {}),
};
});
// Apply to limiter if it exists and has rate limit enabled
if (connectionId && enabledConnections.has(connectionId)) {

View File

@@ -30,6 +30,7 @@
* Statistics are plain arithmetic (EWMA + small counters), O(1) per event, safe
* under the Node event loop's single thread — no lock-free/atomic trickery.
*/
import { boundedMap } from "../../../src/lib/quota/boundedMap.ts";
/** EWMA smoothing factor (alpha). Lower = slower adaptation. */
const OPERATIONAL_ALPHA = 0.2;
@@ -60,7 +61,18 @@ interface QualityState {
lastTs: number;
}
const states = new Map<string, QualityState>();
/**
* Cap on tracked (provider, model) pairs. Only pairs that actually carry traffic
* are tracked, so normal deployments stay far below it; past it the
* least-recently-used pair without an evaluator score is dropped (it restarts
* cold/neutral). Pairs holding a semantic score are never evicted — that score
* only comes from an evaluator run and cannot be re-learned from traffic.
*/
export const QUALITY_STATES_CAP = 4096;
const states = boundedMap<QualityState>("routing-quality", QUALITY_STATES_CAP, "lru", 0, {
shouldEvict: (s) => s.semantic === null,
});
function keyOf(provider: string, model: string): string {
return `${provider}/${model}`;
@@ -273,7 +285,9 @@ export function getQualityScore(provider: string, model: string): number {
/** Full snapshot of the tracker for explainability / dashboard. */
export function getQualitySnapshot(limit = 200): ProviderQuality[] {
const views: ProviderQuality[] = [];
for (const [key] of states) {
// Snapshot copy: LRU get refreshes recency (reinsertion), so iterating live + get()
// would loop forever. Snapshot behavior unchanged.
for (const [key] of [...states]) {
const slash = key.indexOf("/");
const provider = slash >= 0 ? key.slice(0, slash) : key;
const model = slash >= 0 ? key.slice(slash + 1) : key;

View File

@@ -11,6 +11,7 @@
* without real sockets. The ReadableStream wiring lives in `createRecoverableStream`.
*/
import { STREAM_RECOVERY } from "../config/constants.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import {
createThroughputWatchdog,
ThroughputWatchdogError,
@@ -19,6 +20,20 @@ import {
export { ThroughputWatchdogError } from "./throughputWatchdog.ts";
const TOOLCALL_ORDER_FIX_FLAG = "STREAM_RECOVERY_TOOLCALL_ORDER_FIX";
/**
* Read the opt-in tool-call-safe continuation flag. Fail-closed: any resolution failure
* (DB not ready, unknown key) keeps the release behavior.
*/
function isToolcallOrderFixEnabled(): boolean {
try {
return isFeatureFlagEnabled(TOOLCALL_ORDER_FIX_FLAG);
} catch {
return false;
}
}
/** Raised internally when an upstream stream ends without a terminal SSE marker. */
export class TruncatedStreamError extends Error {
constructor(message = "Provider stream ended without a terminal marker") {
@@ -335,6 +350,21 @@ export function trimContinuationOverlap(emitted: string, continuation: string):
return continuation;
}
/** Why a post-commit cut was not continued (see `ContinuationOutcome`). */
export type ContinuationRefusal = "budget" | "tool-call" | "not-continuable";
/**
* Result of one mid-stream continuation decision, for observability only. `attempt` is the
* continuation counter `onContinue` reported (0 when a cut is refused before any attempt).
* A refusal is reported only for an abnormal end (read error, watchdog abort, or a graceful
* end with no terminal marker) of an OpenAI-compatible stream — never for a nominal end.
*/
export type ContinuationOutcome =
| { attempt: number; outcome: "suffix"; suffixChars: number }
| { attempt: number; outcome: "overlap-reject"; overlapChars: number }
| { attempt: number; outcome: "terminal" | "empty" | "no-stream" }
| { attempt: number; outcome: "refused"; reason: ContinuationRefusal };
export interface RecoverableStreamOptions {
/** Released exactly once when the wrapped stream closes, errors, or is cancelled. */
finalize: () => void;
@@ -356,6 +386,8 @@ export interface RecoverableStreamOptions {
maxContinuations?: number;
/** Observability hook fired on each continuation attempt. */
onContinue?: (attempt: number, assistantSoFar: string) => void;
/** Observability hook fired with each continuation outcome or refused cut. */
onContinueOutcome?: (event: ContinuationOutcome) => void;
/** Opt-in active-stream output-quality watchdog. Disabled when omitted. */
throughputWatchdog?: ThroughputWatchdogOptions;
/** Sanitized observability hook fired before the active attempt is aborted. */
@@ -433,7 +465,12 @@ export function createRecoverableStream(
let emittedTerminal = false;
let emittedToolCallInFlight = false;
let emittedSawToolCall = false; // any tool_call delta seen, complete or not
let emittedToolCallFinish = false; // any finish_reason "tool_calls" seen
let emittedParsedOpenAi = false;
// STREAM_RECOVERY_TOOLCALL_ORDER_FIX, resolved lazily at most once per stream and only
// on a recovery decision, so the flag costs nothing on streams that end cleanly.
let toolCallOrderFix: boolean | undefined;
const isToolCallOrderFixOn = () => (toolCallOrderFix ??= isToolcallOrderFixEnabled());
// Enqueue to the client and, when continuation is enabled, fold the chunk into the
// running scan so a later continuation can be prefilled with exactly what was sent.
@@ -455,6 +492,7 @@ export function createRecoverableStream(
if (scan.terminal) emittedTerminal = true;
if (scan.sawToolCallInFlight) emittedToolCallInFlight = true;
if (scan.sawToolCall) emittedSawToolCall = true;
if (scan.finishReason === "tool_calls") emittedToolCallFinish = true;
if (scan.parsedOpenAi) emittedParsedOpenAi = true;
};
@@ -492,12 +530,33 @@ export function createRecoverableStream(
emittedText.length === 0 &&
emittedReasoningText.length > 0;
// With STREAM_RECOVERY_TOOLCALL_ORDER_FIX on, any tool-call activity makes the turn
// non-continuable. The per-batch scan above is order-blind: a batch carrying a finished
// call followed by a new partial call reports nothing in flight, and a call finished with
// finish_reason "tool_calls" is a completed turn where only [DONE] can be missing — a
// continuation there spends an upstream request and appends content plus a second
// finish_reason after the tool-call finish. Every tool call is either still pending or
// already finished, so the order-independent check is exact. Off: the release gate.
const toolCallBlocksContinuation = () =>
(emittedSawToolCall || emittedToolCallFinish) && isToolCallOrderFixOn();
const canContinue = () =>
continueEnabled &&
continuations < maxContinuations &&
emittedParsedOpenAi &&
!emittedToolCallInFlight &&
(emittedText.length > 0 ? !emittedTerminal : hallucinatedEmptyStop());
(emittedText.length > 0 ? !emittedTerminal : hallucinatedEmptyStop()) &&
!toolCallBlocksContinuation();
// Report why a cut is not continued. Silent for non-OpenAI bodies (continuation never
// applies to them) so the hook stays quiet on every Claude/Gemini-format stream end.
const reportRefusal = () => {
if (!continueEnabled || !emittedParsedOpenAi || !options.onContinueOutcome) return;
let reason: ContinuationRefusal = "not-continuable";
if (continuations >= maxContinuations) reason = "budget";
else if (emittedToolCallInFlight || toolCallBlocksContinuation()) reason = "tool-call";
options.onContinueOutcome({ attempt: continuations, outcome: "refused", reason });
};
const emitCleanTerminal = (controller: ReadableStreamDefaultController<Uint8Array>) => {
controller.enqueue(
@@ -509,12 +568,18 @@ export function createRecoverableStream(
// Re-request from the partial text and stitch the missing suffix into the client stream.
// Returns true once the recovered stream has been terminated (caller closes); false to
// fall back to the unchanged #4131 error/close behavior.
// `cut` is false only for a graceful end that carried a terminal marker (nominal end).
const tryContinue = async (
controller: ReadableStreamDefaultController<Uint8Array>
controller: ReadableStreamDefaultController<Uint8Array>,
cut = true
): Promise<boolean> => {
if (!canContinue()) return false;
if (!canContinue()) {
if (cut) reportRefusal();
return false;
}
continuations += 1;
options.onContinue?.(continuations, emittedText);
const report = (event: ContinuationOutcome) => options.onContinueOutcome?.(event);
let contStream: ReadableStream<Uint8Array> | null = null;
try {
@@ -522,7 +587,10 @@ export function createRecoverableStream(
} catch {
contStream = null;
}
if (!contStream) return false;
if (!contStream) {
report({ attempt: continuations, outcome: "no-stream" });
return false;
}
// Drain the continuation fully (recovery favors correctness over token-by-token
// streaming of the recovered tail), then emit only the de-duplicated suffix.
@@ -554,6 +622,7 @@ export function createRecoverableStream(
scan.text.length > 0 &&
overlapChars < STREAM_RECOVERY.MIN_CONTINUATION_OVERLAP_CHARS;
if (isSuspectedRestart) {
report({ attempt: continuations, outcome: "overlap-reject", overlapChars });
if (await tryContinue(controller)) return true;
emitCleanTerminal(controller);
return true;
@@ -566,9 +635,26 @@ export function createRecoverableStream(
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: suffix } }] })}\n\n`
)
);
report({ attempt: continuations, outcome: "suffix", suffixChars: suffix.length });
}
// A clean finish, or a tool call we cannot safely stitch, ends the recovered stream.
if (scan.terminal || scan.sawToolCall) {
if (!suffix) report({ attempt: continuations, outcome: "terminal" });
emitCleanTerminal(controller);
return true;
}
// With STREAM_RECOVERY_TOOLCALL_ORDER_FIX on, a continuation that delivered no text
// carries no new information (the next re-request replays the same prefill), so close
// after this one spent request instead of burning the rest of the budget.
if (scan.text.length === 0 && isToolCallOrderFixOn()) {
report({ attempt: continuations, outcome: "empty" });
emitCleanTerminal(controller);
return true;
}
// With STREAM_RECOVERY_TOOLCALL_ORDER_FIX on, a continuation that delivered no text
// carries no new information (the next re-request replays the same prefill), so close
// after this one spent request instead of burning the rest of the budget.
if (scan.text.length === 0 && isToolCallOrderFixOn()) {
emitCleanTerminal(controller);
return true;
}
@@ -619,7 +705,7 @@ export function createRecoverableStream(
// says the stream is worth continuing (silent truncation, or a clean-but-empty
// reasoning-only stop) — canContinue() is the single source of truth here, same as
// the read-error branch above.
if (await tryContinue(controller)) {
if (await tryContinue(controller, !emittedTerminal)) {
runFinalize();
controller.close();
return;

View File

@@ -18,6 +18,12 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
// GHSA-r4q7-7f24-m29p: Groq (`gsk_` + 52) and xAI (`xai-` + 80) had no entry, so both
// the opt-in guardrail and the public error sanitizer echoed them verbatim. Lower bound
// only, for the same reason as `google` below — an error body that over-redacts a
// look-alike costs nothing; one that under-redacts leaks a credential.
{ name: "groq", regex: /\bgsk_[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:groq]" },
{ name: "xai", regex: /\bxai-[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:xai]" },
// {20,} rather than the exact {35} of a standard 39-char Google API key. #12506 added
// this pattern with the exact length; #12620 landed the anti-drift test that asserts
// /\bAIza[A-Za-z0-9_-]{20,}/ must not survive. Anything shorter or longer than 39 was
@@ -82,4 +88,17 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
replacement: "$1[REDACTED:auth_header]",
},
// GHSA-r4q7-7f24-m29p: generic `sk-` fallback for every OpenAI-compatible provider whose
// key is not exactly 48 chars (DeepSeek 32-hex, Moonshot/Kimi 47-49, Together, …). The
// guardrail is catalog-only, so all of those passed through it untouched. MUST stay the
// LAST entry: both consumers iterate in order and replace as they go, so `openai_proj`,
// `openai` and `anthropic*` have already stamped their specific label before this one
// runs — it only ever sees the `sk-` shapes nothing else claimed. The lookbehind
// (mirroring STRONG_CREDENTIAL_TOKEN in errorSanitization.ts) keeps `risk-…`-style words
// from matching.
{
name: "openai_compatible",
regex: /(?<![A-Za-z0-9])sk-[A-Za-z0-9._~+/=-]{20,}/g,
replacement: "[REDACTED:openai_compatible]",
},
];

View File

@@ -19,12 +19,9 @@ import {
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CURSOR_AGENT_CLI_VERSION } from "./cursorAgentCliVersionPin.ts";
/**
* Pinned Agent CLI build id used when no local install is found (typical
* headless OmniRoute). Bump when refreshing Cursor CLI impersonation.
*/
export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a";
export { CURSOR_AGENT_CLI_VERSION };
const VERSION_ID_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/;
const CACHE_TTL_MS = 60 * 60 * 1000;

View File

@@ -0,0 +1,7 @@
// Import-free pin so client bundles can read the version without pulling node-only detection code.
/**
* Pinned Agent CLI build id used when no local install is found (typical
* headless OmniRoute). Bump when refreshing Cursor CLI impersonation.
*/
export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a";

View File

@@ -9,6 +9,7 @@ import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails };
@@ -674,6 +675,41 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null):
return 1;
}
/**
* #13672 — opt-in RETRY_AFTER_PROVENANCE_ENABLED (default off). Fails closed to
* the legacy Retry-After contract when the flag store cannot be read.
*/
export function isRetryAfterProvenanceEnabled(): boolean {
try {
return isFeatureFlagEnabled("RETRY_AFTER_PROVENANCE_ENABLED");
} catch {
return false;
}
}
/**
* Seconds until a concrete FUTURE retry time, or null when there is none: absent,
* non-positive or invalid values, numeric strings, and dates that already elapsed.
* Unlike normalizeRetryAfterSeconds it never invents a 1s wait; when it returns a
* number, that number equals normalizeRetryAfterSeconds for the same input.
*/
export function resolveRetryAfterHintSeconds(
retryAfter?: string | number | Date | null
): number | null {
if (typeof retryAfter === "number") {
if (!Number.isFinite(retryAfter) || retryAfter <= 0) return null;
if (retryAfter < 1_000_000_000) return Math.max(Math.ceil(retryAfter), 1);
} else if (typeof retryAfter === "string") {
if (retryAfter.trim() === "" || !Number.isNaN(Number(retryAfter))) return null;
} else if (!(retryAfter instanceof Date)) {
return null;
}
const now = Date.now();
const retryTimeMs = new Date(retryAfter).getTime();
if (!Number.isFinite(retryTimeMs) || retryTimeMs <= now) return null;
return Math.max(Math.ceil((retryTimeMs - now) / 1000), 1);
}
const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256;
function projectPublicContextLabel(value: unknown): string | null {
@@ -733,6 +769,49 @@ export function parseAntigravityRetryTime(message: unknown): number | null {
return totalMs > 0 ? totalMs : null;
}
const MAX_PROSE_RETRY_MS = 24 * 60 * 60 * 1000;
/**
* Retry delay in ms from upstream error prose (Antigravity "reset after 2h7m23s",
* generic "retry after 30s"), capped at 24h; null when the text carries no hint.
*/
export function parseProseRetryDelayMs(text: unknown): number | null {
if (typeof text !== "string" || text === "") return null;
const antigravityMs = parseAntigravityRetryTime(text);
if (antigravityMs) return Math.min(antigravityMs, MAX_PROSE_RETRY_MS);
const m = /retry\s+after\s+(\d{1,9})\s*s/i.exec(text);
const ms = m ? Number.parseInt(m[1], 10) * 1000 : 0;
return ms > 0 ? Math.min(ms, MAX_PROSE_RETRY_MS) : null;
}
/**
* Combo drain paths: ISO retry time read from the prose of an upstream error body,
* JSON or plain text. Null when RETRY_AFTER_PROVENANCE_ENABLED is off (legacy:
* only structured retry fields are read) or when the text carries no hint.
*/
export function readProseRetryAfter(text: unknown): string | null {
if (!isRetryAfterProvenanceEnabled()) return null;
const ms = parseProseRetryDelayMs(text);
return ms ? new Date(Date.now() + ms).toISOString() : null;
}
/**
* Combo drain paths: the upstream error body could not be read for a retry hint.
* A non-JSON body (an HTML 502 page, plain text) is ordinary, so it logs at debug;
* a failed clone means the body was already consumed and logs at warn.
*/
export function logRetryHintUnreadable(
log: { warn: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void },
tag: string,
model: string,
status: number | undefined,
reason: "unparseable body" | "clone failed"
): void {
const message = `Retry hint unreadable for ${model} (${reason})`;
if (reason === "clone failed") log.warn(tag, message, { status });
else log.debug?.(tag, message, { status });
}
/**
* Parse upstream provider error response
* @param {Response} response - Fetch response from provider
@@ -925,15 +1004,23 @@ export function unavailableResponse(
retryAfter?: string | number | Date | null,
retryAfterHuman?: string
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
// #13672 (opt-in): only a concrete future retry time earns a Retry-After header, and the
// body says whether one existed. Off: legacy header, always present and clamped to >= 1s.
const provenance = isRetryAfterProvenanceEnabled();
const retryAfterSec = provenance
? resolveRetryAfterHintSeconds(retryAfter)
: normalizeRetryAfterSeconds(retryAfter);
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : "";
const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage;
return new Response(JSON.stringify({ error: { message: msg } }), {
const error = provenance
? { message: msg, retry_after_provenance: retryAfterSec === null ? "none" : "signal" }
: { message: msg };
return new Response(JSON.stringify({ error }), {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
...(retryAfterSec === null ? {} : { "Retry-After": String(retryAfterSec) }),
},
});
}

View File

@@ -1,5 +1,6 @@
import "./setupPolyfill.ts";
import { Agent, ProxyAgent, type Dispatcher } from "undici";
import { decodeUserinfo } from "@/shared/utils/decodeUserinfo";
import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
import { stripIpv6Brackets, detectIpLiteralFamily, parseProxyFamily } from "./proxyFamily.ts";
import { createSocksDispatcherWithFamily } from "./socksConnectorWithFamily.ts";
@@ -248,8 +249,7 @@ function normalizePort(port: string | number | null | undefined, protocol: strin
* listen on these ports, so we must always include the port explicitly.
*/
function buildProxyUrlString(parsed: URL, port: string): string {
const auth =
parsed.username || parsed.password ? `${parsed.username}:${parsed.password}@` : "";
const auth = parsed.username || parsed.password ? `${parsed.username}:${parsed.password}@` : "";
return `${parsed.protocol}//${auth}${parsed.hostname}:${port}`;
}
@@ -436,6 +436,23 @@ export function __createRoundRobinDispatcherForTest(dispatchers: Dispatcher[]):
return createRoundRobinDispatcher(dispatchers);
}
/**
* `Proxy-Authorization` value for an HTTP(S) proxy URL carrying userinfo, or null.
*
* undici's ProxyAgent builds this header itself with a bare `decodeURIComponent` on the
* URL's username/password, which throws `URIError` for a credential holding a literal
* `%` (e.g. `pa%ss`) — the dispatcher could not even be constructed. We build the same
* header (same `Basic base64(user:pass)` / `user:` shapes undici emits) with the guarded
* decoder and hand it over as `token`, so undici never decodes. Correctly encoded
* credentials (`user%40corp`) produce exactly the header undici produced before.
*/
function buildProxyAuthorizationToken(parsed: URL): string | null {
if (!parsed.username) return null;
const user = decodeUserinfo(parsed.username);
const pass = parsed.password ? decodeUserinfo(parsed.password) : "";
return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`;
}
/**
* Build a ProxyAgent / socks dispatcher for a normalized proxy URL using the
* given options. Shared by the pooled dispatcher (keep-alive, pipelining 4)
@@ -458,8 +475,8 @@ function buildProxyDispatcher(
host: stripIpv6Brackets(parsed.hostname),
port: Number(port),
};
if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username);
if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password);
if (parsed.username) socksOptions.userId = decodeUserinfo(parsed.username);
if (parsed.password) socksOptions.password = decodeUserinfo(parsed.password);
return createSocksDispatcherWithFamily(
socksOptions as unknown as Parameters<typeof createSocksDispatcherWithFamily>[0],
family,
@@ -473,6 +490,7 @@ function buildProxyDispatcher(
// `{ family, autoSelectFamily }` pin. At runtime undici merges these options into
// net.connect (the uri already carries the host:port), so the partial pin is
// valid; the cast suppresses the spurious missing-`port` error.
const proxyAuthorization = buildProxyAuthorizationToken(parsed);
return new ProxyAgent({
uri: cleanUri,
// undici 8.6+ forwards plain-HTTP requests through the proxy as an origin
@@ -482,6 +500,7 @@ function buildProxyDispatcher(
// undici <8.6 → silently ignored (that version already tunneled by default).
proxyTunnel: true,
...options,
...(proxyAuthorization ? { token: proxyAuthorization } : {}),
...(family !== null
? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] }
: {}),
@@ -553,7 +572,7 @@ export function __getSocksOptionsForTest(proxyUrl: string): SocksDispatcherOptio
host: stripIpv6Brackets(parsed.hostname),
port: Number(port),
};
if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username);
if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password);
if (parsed.username) socksOptions.userId = decodeUserinfo(parsed.username);
if (parsed.password) socksOptions.password = decodeUserinfo(parsed.password);
return socksOptions;
}

View File

@@ -12,6 +12,7 @@ import { fetch as undiciFetch } from "undici";
import { createProxyDispatcher, normalizeProxyUrl } from "./proxyDispatcher.ts";
import { resolveProxyForScopeFromRegistry, listProxies } from "@/lib/db/proxies";
import { listOneproxyProxies } from "@/lib/db/oneproxy";
import { decodeUserinfo } from "@/shared/utils/decodeUserinfo";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
// ---------------------------------------------------------------------------
@@ -427,8 +428,8 @@ export async function selectWorkingProxyFallback(_connectionId?: string): Promis
type: url.protocol.replace(":", "") || "http",
host: url.hostname,
port: parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),
username: url.username ? decodeURIComponent(url.username) : "",
password: url.password ? decodeURIComponent(url.password) : "",
username: url.username ? decodeUserinfo(url.username) : "",
password: url.password ? decodeUserinfo(url.password) : "",
},
level: "autoSelect",
levelId: null,

View File

@@ -642,6 +642,33 @@ export function normalizeUsage(usage: UsageLike | null | undefined) {
return normalized;
}
// Internal marker for usage that was estimated locally (a web/cookie executor with no
// upstream metering). A NON-enumerable symbol: JSON.stringify, object spread and
// filterUsageForFormat never copy it, so it cannot reach a client payload or change any
// usage field, cost or budget — it only lets the call-log sink tell estimated usage apart
// after extraction rebuilt the object without the provider's `estimated` flag.
const ESTIMATED_USAGE_MARKER = Symbol.for("omniroute.usage.estimated");
export function carryEstimatedUsageMarker<T>(source: unknown, rebuilt: T): T {
const estimated =
!!source && typeof source === "object" && (source as UsageLike).estimated === true;
if (estimated && rebuilt && typeof rebuilt === "object") {
Object.defineProperty(rebuilt, ESTIMATED_USAGE_MARKER, { value: true, enumerable: false });
}
return rebuilt;
}
/**
* True when token usage was estimated locally instead of reported by the provider: either
* the usage still carries `estimated: true` (OmniRoute's own estimateUsage fallback) or
* extraction kept the internal marker. Observability only — billing does not read it.
*/
export function isEstimatedUsage(usage: unknown): boolean {
if (!usage || typeof usage !== "object") return false;
if ((usage as UsageLike).estimated === true) return true;
return Reflect.get(usage, ESTIMATED_USAGE_MARKER) === true;
}
/**
* Check if usage has valid token data
* Valid = has at least one token field with value > 0
@@ -786,7 +813,7 @@ export function extractUsage(chunk: UsagePayloadLike | null | undefined) {
typeof chunk.usage === "object" &&
(chunk.usage.prompt_tokens !== undefined || chunk.usage.input_tokens !== undefined)
) {
return normalizeUsage({
const normalized = normalizeUsage({
prompt_tokens: chunk.usage.prompt_tokens ?? chunk.usage.input_tokens ?? 0,
completion_tokens: chunk.usage.completion_tokens ?? chunk.usage.output_tokens ?? 0,
cached_tokens:
@@ -804,6 +831,7 @@ export function extractUsage(chunk: UsagePayloadLike | null | undefined) {
// xAI's exact provider-reported cost (port of decolua/9router#2453, capability A).
cost_in_usd_ticks: chunk.usage.cost_in_usd_ticks,
});
return carryEstimatedUsageMarker(chunk.usage, normalized);
}
// Gemini format (Antigravity)

6
package-lock.json generated
View File

@@ -15484,9 +15484,9 @@
}
},
"node_modules/adm-zip": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
"integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
"license": "MIT",
"optional": true,
"engines": {

View File

@@ -201,6 +201,7 @@
"check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs",
"check:build-scope": "node scripts/check/check-build-scope.mjs",
"check:error-helper": "node scripts/check/check-error-helper.mjs",
"check:routing-error-guard": "node scripts/check/check-routing-error-guard.mjs",
"check:migration-numbering": "node scripts/check/check-migration-numbering.mjs",
"check:public-creds": "node scripts/check/check-public-creds.mjs",
"check:db-rules": "node scripts/check/check-db-rules.mjs",
@@ -503,7 +504,7 @@
"concurrently": {
"shell-quote": "^1.9.0"
},
"adm-zip": "^0.6.0",
"adm-zip": "^0.6.1",
"promptfoo": {
"js-yaml": "^5.2.2",
"undici": "^7.29.0"

View File

@@ -0,0 +1,372 @@
{
"$schema": "allowlist-routing-swallowed-catch",
"_comment": "Frozen swallowed catches on routing paths for scripts/check/check-routing-error-guard.mjs. Keyed by file + normalized catch-body snippet (not line numbers); count = identical bodies in that file. Do NOT add entries without a justification; shrink or remove an entry when its catch is fixed.",
"entries": [
{
"file": "open-sse/services/combo.ts",
"snippet": "// keep empty stats — auto-combo will use runtime + bootstrap signals",
"count": 1,
"reason": "stats fallback to defaults, auto path uses runtime signals"
},
{
"file": "open-sse/services/combo.ts",
"snippet": "connectionPoolCounts.set(provider, 0); connectionsByProvider.set(provider, []);",
"count": 1,
"reason": "pool counts fallback to empty lists"
},
{
"file": "open-sse/services/combo.ts",
"snippet": "// keep default cost",
"count": 1,
"reason": "cost fallback to default pricing"
},
{
"file": "open-sse/services/combo.ts",
"snippet": "log?.debug?.( \"COMBO\", `resolveTargetTimeoutMsForTarget connection lookup failed: ${ err instanceof Error ? err.message ",
"count": 1,
"reason": "logged at debug, undefined fallback"
},
{
"file": "open-sse/services/combo/applyStrategyOrdering.ts",
"snippet": "log.warn(\"COMBO\", \"Failed to retrieve Last Known Good Provider. This is non-fatal.\", { err });",
"count": 1,
"reason": "logged, best-effort provider read fallback"
},
{
"file": "open-sse/services/combo/applyStrategyOrdering.ts",
"snippet": "log.warn({ err }, \"manifest routing failed, falling back to standard strategy\");",
"count": 1,
"reason": "logged, manifest routing fallback"
},
{
"file": "open-sse/services/combo/autoStrategy.ts",
"snippet": "log.warn?.( \"COMBO\", `Tag routing failed to load connections for provider=${providerId}: ${error instanceof Error ? erro",
"count": 1,
"reason": "logged, tag routing connections fallback"
},
{
"file": "open-sse/services/combo/autoStrategy.ts",
"snippet": "// Best-effort candidate expansion only: if loading active connections or // provider models fails, fall back to the exp",
"count": 1,
"reason": "expanded targets fallback, abort-safe"
},
{
"file": "open-sse/services/combo/autoStrategy.ts",
"snippet": "return null;",
"count": 1,
"reason": "null fallback, best-effort expansion"
},
{
"file": "open-sse/services/combo/comboPredicates.ts",
"snippet": "// A DB read failure must never block dispatch — fall through to the upstream call. return null;",
"count": 1,
"reason": "null fallback, DB read failure"
},
{
"file": "open-sse/services/combo/concurrencyCaps.ts",
"snippet": "return null; // fail-open: never block routing on a lookup error",
"count": 1,
"reason": "null fallback, fail-open routing"
},
{
"file": "open-sse/services/combo/connectionAwareExpansion.ts",
"snippet": "// Fail-open (spec section 3.1): expansion is a best-effort pre-filter, never a // hard dependency. Auth-layer gates rem",
"count": 1,
"reason": "logged, fail-open expansion"
},
{
"file": "open-sse/services/combo/dispatchPrelude.ts",
"snippet": "return false;",
"count": 1,
"reason": "false fallback, pinned dispatch check"
},
{
"file": "open-sse/services/combo/dispatchPrelude.ts",
"snippet": "pinnedClone = pinnedResult;",
"count": 1,
"reason": "pinned clone fallback, release on failure"
},
{
"file": "open-sse/services/combo/dispatchPrelude.ts",
"snippet": "log.warn( \"COMBO\", `Pinned model ${pinnedModel} threw error: ${pinErr instanceof Error ? pinErr.message : String(pinErr)",
"count": 1,
"reason": "logged, pinned model fallthrough"
},
{
"file": "open-sse/services/combo/executeTargetAttempt.ts",
"snippet": "qualityClone = result;",
"count": 1,
"reason": "clone fallback to original response"
},
{
"file": "open-sse/services/combo/executeTargetAttempt.ts",
"snippet": "deps.log.warn( \"COMBO\", \"Failed to record Last Known Good Provider. This is non-fatal.\", { err, } );",
"count": 1,
"reason": "logged at warn, success-path best-effort LKGP persist (failure only loses a routing optimization)"
},
{
"file": "open-sse/services/combo/executeTargetAttempt.ts",
"snippet": "/* Clone parse failed */",
"count": 1,
"reason": "nested clone-parse fallback, error text preserved"
},
{
"file": "open-sse/services/combo/executeTargetAttempt.ts",
"snippet": "/* Clone failed */",
"count": 1,
"reason": "clone fallback, error parse skipped"
},
{
"file": "open-sse/services/combo/executeTargetAttempt.ts",
"snippet": "errorText = String(errorText);",
"count": 1,
"reason": "stringify fallback to String()"
},
{
"file": "open-sse/services/combo/failureTracker.ts",
"snippet": "// Best effort — the counter still records the streak, future clears will // retry on the next threshold-cross.",
"count": 1,
"reason": "counter kept, retry on next threshold"
},
{
"file": "open-sse/services/combo/failureTracker.ts",
"snippet": "return { count: 0, pinClearedNow: false };",
"count": 1,
"reason": "zeroed streak fallback"
},
{
"file": "open-sse/services/combo/failureTracker.ts",
"snippet": "/* fail-open */",
"count": 1,
"reason": "fail-open tracker state fallback"
},
{
"file": "open-sse/services/combo/failureTracker.ts",
"snippet": "return 0;",
"count": 1,
"reason": "zero fallback, fail-open counter"
},
{
"file": "open-sse/services/combo/nativeCodexTurnPin.ts",
"snippet": "return undefined;",
"count": 1,
"reason": "undefined fallback, best-effort pin"
},
{
"file": "open-sse/services/combo/promptCacheAffinity.ts",
"snippet": "return \"\";",
"count": 1,
"reason": "empty-string fallback"
},
{
"file": "open-sse/services/combo/promptCacheAffinity.ts",
"snippet": "connectionsByProvider.set(provider, []);",
"count": 1,
"reason": "connections fallback to empty list"
},
{
"file": "open-sse/services/combo/providerWildcard.ts",
"snippet": "return modelIds;",
"count": 1,
"reason": "model list fallback"
},
{
"file": "open-sse/services/combo/quotaExhaustion.ts",
"snippet": "try { text = await response.clone().text(); } catch { // The status and trusted in-process classification remain availab",
"count": 1,
"reason": "status preserved, cloned text fallback"
},
{
"file": "open-sse/services/combo/quotaExhaustionCutoff.ts",
"snippet": "connection = undefined;",
"count": 1,
"reason": "undefined connection fallback"
},
{
"file": "open-sse/services/combo/quotaExhaustionCutoff.ts",
"snippet": "// Fail-open: never block routing because the preflight fetch itself errored. return { blocked: false };",
"count": 1,
"reason": "fail-open, blocked false"
},
{
"file": "open-sse/services/combo/quotaShareConcurrency.ts",
"snippet": "// Fail-open: a saturated queue / timeout must never worsen availability — // proceed without a slot rather than reject ",
"count": 1,
"reason": "fail-open, proceed without a slot"
},
{
"file": "open-sse/services/combo/quotaStrategies.ts",
"snippet": "log.warn?.(\"COMBO\", \"Reset-aware failed to load quota-aware connections.\", { comboName, err: error, operation: \"getProvi",
"count": 1,
"reason": "logged, quota-aware connections fallback"
},
{
"file": "open-sse/services/combo/quotaStrategies.ts",
"snippet": "log.warn?.(\"COMBO\", \"Reset-aware quota fetch failed.\", { comboName, connectionId, err: error, operation: \"quotaFetch\", p",
"count": 1,
"reason": "logged, reset-aware quota fetch fallback"
},
{
"file": "open-sse/services/combo/quotaStrategies.ts",
"snippet": "log.warn?.( { err: (err as Error)?.message, comboName }, \"headroom ordering failed — keeping target order\" ); return tar",
"count": 1,
"reason": "logged, headroom ordering kept"
},
{
"file": "open-sse/services/combo/resolveAutoStrategy.ts",
"snippet": "log.warn(\"COMBO\", \"Failed to retrieve Last Known Good Provider. This is non-fatal.\", { err });",
"count": 1,
"reason": "logged, provider read best-effort"
},
{
"file": "open-sse/services/combo/resolveAutoStrategy.ts",
"snippet": "log.warn( \"COMBO\", `Auto strategy '${routingStrategy}' failed (${err?.message || \"unknown\"}), falling back to rules` );",
"count": 1,
"reason": "logged, auto strategy rules fallback"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "return undefined;",
"count": 1,
"reason": "undefined fallback, quota path unaffected"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "// best-effort only",
"count": 1,
"reason": "best-effort quota reserve only"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "rrClone = result;",
"count": 1,
"reason": "clone fallback to original"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "log.warn( \"COMBO-RR\", \"Failed to record Last Known Good Provider. This is non-fatal.\", { err, } );",
"count": 1,
"reason": "logged at warn, success-path best-effort LKGP persist (failure only loses a routing optimization)"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "/* Clone parse failed */",
"count": 1,
"reason": "clone-parse fallback, error text preserved"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "/* Clone failed */",
"count": 1,
"reason": "clone fallback, error parse skipped"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "errorText = String(errorText);",
"count": 1,
"reason": "stringify fallback to String()"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"snippet": "// G4: unexpected exception in the round-robin loop must never crash the // request silently — surface a 500 instead of ",
"count": 1,
"reason": "logged at error, 500 response surfaced"
},
{
"file": "open-sse/services/combo/runtimeUnits.ts",
"snippet": "unitClone = response;",
"count": 1,
"reason": "clone fallback to original response"
},
{
"file": "open-sse/services/combo/sessionStickiness.ts",
"snippet": "return undefined;",
"count": 2,
"reason": "undefined fallback, cooldown read"
},
{
"file": "open-sse/services/combo/sessionStickiness.ts",
"snippet": "return false;",
"count": 1,
"reason": "false fallback, sticky write best-effort"
},
{
"file": "open-sse/services/combo/sessionStickiness.ts",
"snippet": "// Completely unexpected error — fail-open return noOp;",
"count": 1,
"reason": "no-op fallback, fail-open stickiness"
},
{
"file": "open-sse/services/combo/shadowRouting.ts",
"snippet": "// Shadow draining is best-effort and must never affect the production response.",
"count": 1,
"reason": "best-effort shadow drain only"
},
{
"file": "open-sse/services/combo/shadowRouting.ts",
"snippet": "log.warn(\"COMBO\", \"Shadow routing skipped: failed to clone request body\", { error: error instanceof Error ? error.messag",
"count": 1,
"reason": "logged, shadow body clone skipped"
},
{
"file": "open-sse/services/combo/shadowRouting.ts",
"snippet": "recordComboShadowRequest(combo.name, target.modelStr, { success: false, latencyMs: Date.now() - startedAt, target: toRec",
"count": 1,
"reason": "combo shadow request recorded as failed"
},
{
"file": "open-sse/services/combo/targetResolution.ts",
"snippet": "logPipelineFallthrough(pipelineErr, log); return null;",
"count": 1,
"reason": "logged, pipeline fallthrough to null"
},
{
"file": "open-sse/services/combo/targetSorters.ts",
"snippet": "return { modelStr, cost: Infinity };",
"count": 1,
"reason": "infinite-cost fallback"
},
{
"file": "open-sse/services/combo/targetSorters.ts",
"snippet": "// If pricing lookup fails entirely, return original order return models;",
"count": 1,
"reason": "original order fallback"
},
{
"file": "open-sse/services/combo/targetTimeoutRunner.ts",
"snippet": "// Diagnostic logging failed — never let this break the process.",
"count": 1,
"reason": "diagnostic logging failed"
},
{
"file": "open-sse/services/combo/validateQuality.ts",
"snippet": "return null;",
"count": 1,
"reason": "null fallback, quality check skipped"
},
{
"file": "open-sse/services/combo/validateQuality.ts",
"snippet": "controller.close();",
"count": 1,
"reason": "controller closed, stream cleanup"
},
{
"file": "open-sse/services/combo/validateQuality.ts",
"snippet": "// If reading the stream fails due to a locked stream or pipe error, // the content cannot be verified — mark as invalid",
"count": 1,
"reason": "invalid fallback, unverifiable stream"
},
{
"file": "open-sse/services/combo/validateQuality.ts",
"snippet": "return { valid: true };",
"count": 2,
"reason": "valid fallback, teardown race"
},
{
"file": "open-sse/services/combo/validateQuality.ts",
"snippet": "// An SSE stream body is expected for streamed upstreams. Besides `data:` and // `event:` frames, the SSE spec also allo",
"count": 1,
"reason": "comment-line SSE frame skipped"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "allowlist-void-async",
"entries": [
{
"file": "open-sse/services/combo/executeTargetAttempt.ts",
"anchor": "Failed to record Last Known Good Provider",
"reason": "success-path best-effort persist; failure only loses an optimization and is logged"
},
{
"file": "open-sse/services/combo/roundRobinCombo.ts",
"anchor": "Failed to record Last Known Good Provider",
"reason": "same as above, round-robin success path"
}
]
}

View File

@@ -53,6 +53,26 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"src/app/api/cli-tools/forge-settings", // GET calls getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263)
"src/app/api/cli-tools/jcode-settings", // GET calls getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263)
"src/app/api/cli-tools/qwen-settings", // GET calls getCliRuntimeStatus("qwen") and writes local ~/.qwen config files (Hard Rules #15 + #17)
// GHSA-35fw-cv32-2373: the 14 cli-tools routes that reach the same spawn as the siblings
// above via getCliRuntimeStatus() (13) or detectAllTools() -> execFile (detect).
"src/app/api/cli-tools/all-statuses", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/claude-settings", // GET calls getCliRuntimeStatus() to detect the `claude` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/cline-settings", // GET calls getCliRuntimeStatus() to detect the `cline` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/codewhale-settings", // GET calls getCliRuntimeStatus() to detect the `codewhale` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/codex-settings", // GET calls getCliRuntimeStatus() to detect the `codex` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/crush-settings", // GET calls getCliRuntimeStatus() to detect the `crush` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/deepseek-tui-settings", // GET calls getCliRuntimeStatus() to detect the `deepseek-tui` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/detect", // GET calls detectAllTools() -> execFile(binary, --version) + execFile("which") per tool via src/lib/cli-helper/tool-detector.ts (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/droid-settings", // GET calls getCliRuntimeStatus() to detect the `droid` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/kilo-settings", // GET calls getCliRuntimeStatus() to detect the `kilo` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/openclaw-settings", // GET calls getCliRuntimeStatus() to detect the `openclaw` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/pi-settings", // GET calls getCliRuntimeStatus() to detect the `pi` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/smelt-settings", // GET calls getCliRuntimeStatus() to detect the `smelt` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
"src/app/api/cli-tools/status", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373)
// GHSA-jx89-f37j-pq89: skills install + execute reach childProcess.spawn transitively
// (executor.ts -> builtins.ts -> sandbox.ts) — invisible to the source-scan subcheck.
"src/app/api/skills/install", // POST stores handlerCode verbatim; a built-in name aliases execute_command / eval_code (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89)
"src/app/api/skills/executions", // POST runs skillExecutor.execute() -> sandbox container spawn (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89)
];
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env node
// scripts/check/check-routing-error-guard.mjs
// Gate: swallowed `catch` blocks and fire-and-forget `void (async ...)` on routing
// paths (open-sse/services/combo.ts + open-sse/services/combo/).
//
// Run with `npm run check:routing-error-guard`. It is NOT wired into CI; run it when
// touching routing error handling.
//
// Rule A (swallowed-catch): a `catch` block with no `throw` and no inline
// `// no-effect: <motif>` marker is a violation unless frozen in
// scripts/check/allowlist-routing-swallowed-catch.json. Entries are keyed by file +
// the normalized catch-body snippet (never by line number, so unrelated edits that
// shift lines do not break the gate) with a `count` for identical bodies in one file.
// More live catches than the frozen count → violation; fewer → stale entry (anti-rot:
// lower the count or remove the entry). Chained `.catch(...)` promise handlers are
// ignored by construction.
//
// Rule B (void-async): `void (async` is a violation unless an entry in
// scripts/check/allowlist-void-async.json names the file and an `anchor` substring
// found within the next VOID_ASYNC_ANCHOR_WINDOW lines of that site; a `reason` is
// mandatory and entries matching no site are stale.
//
// Output mirrors scripts/check/check-error-helper.mjs: `file:line :: rule :: hint`.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const cwd = process.cwd();
const SCOPE_FILES = [path.join(cwd, "open-sse/services/combo.ts")];
const SCOPE_DIRS = [path.join(cwd, "open-sse/services/combo")];
const VOID_ASYNC_ALLOWLIST_PATH = path.join(cwd, "scripts/check/allowlist-void-async.json");
const SWALLOWED_CATCH_ALLOWLIST_PATH = path.join(
cwd,
"scripts/check/allowlist-routing-swallowed-catch.json"
);
const NO_EFFECT_MARKER = /\/\/\s*no-effect\s*:/;
const THROW_PATTERN = /\bthrow\b/;
const VOID_ASYNC_PATTERN = /\bvoid\s*\(\s*async\b/;
export const SNIPPET_MAX_LENGTH = 120;
export const VOID_ASYNC_ANCHOR_WINDOW = 25;
function stripStringsAndComments(source) {
// Length-preserving mask: every string/comment char becomes a space (newlines
// kept) so offsets and line numbers survive. Keyword scans use the masked copy;
// marker reads and snippets use the raw slice at the same offsets.
const chars = source.split("");
const blank = (from, to) => {
for (let i = from; i < to; i++) if (chars[i] !== "\n") chars[i] = " ";
};
let i = 0;
while (i < chars.length) {
const c = chars[i];
const next = chars[i + 1];
if (c === "/" && next === "/") {
let j = i;
while (j < chars.length && chars[j] !== "\n") j++;
blank(i, j);
i = j;
} else if (c === "/" && next === "*") {
const end = source.indexOf("*/", i + 2);
const j = end === -1 ? chars.length : end + 2;
blank(i, j);
i = j;
} else if (c === '"' || c === "'" || c === "`") {
let j = i + 1;
while (j < chars.length && (chars[j] !== c || chars[j - 1] === "\\") && chars[j] !== "\n")
j++;
blank(i, Math.min(j + 1, chars.length));
i = Math.min(j + 1, chars.length);
} else {
i++;
}
}
return chars.join("");
}
function skipBalanced(masked, i, open, close) {
let depth = 0;
while (i < masked.length) {
if (masked[i] === open) depth++;
else if (masked[i] === close) {
depth--;
if (depth === 0) return i;
}
i++;
}
return -1;
}
function findCatchBlocks(source) {
const masked = stripStringsAndComments(source);
const blocks = [];
const catchKeyword = /\bcatch\b/g;
let match;
while ((match = catchKeyword.exec(masked)) !== null) {
if (match.index > 0 && masked[match.index - 1] === ".") continue;
let i = match.index + 5;
while (i < masked.length && /\s/.test(masked[i])) i++;
if (masked[i] === "(") {
const closeParen = skipBalanced(masked, i, "(", ")");
if (closeParen === -1) continue;
i = closeParen + 1;
}
while (i < masked.length && /\s/.test(masked[i])) i++;
if (masked[i] !== "{") continue;
const end = skipBalanced(masked, i, "{", "}");
if (end === -1) continue;
blocks.push({
line: source.slice(0, match.index).split("\n").length,
body: source.slice(i + 1, end),
maskedBody: masked.slice(i + 1, end),
});
catchKeyword.lastIndex = end + 1;
}
return blocks;
}
/** Line-independent identity of a catch body: whitespace-collapsed raw text, truncated. */
export function catchSnippet(body) {
return body.replace(/\s+/g, " ").trim().slice(0, SNIPPET_MAX_LENGTH);
}
/** Every catch that neither rethrows nor carries a `// no-effect:` marker. */
export function collectSwallowedCatches(files) {
const swallowed = [];
for (const { path: rel, source } of files) {
for (const block of findCatchBlocks(source)) {
if (THROW_PATTERN.test(block.maskedBody)) continue;
if (NO_EFFECT_MARKER.test(block.body)) continue;
swallowed.push({ file: rel, line: block.line, snippet: catchSnippet(block.body) });
}
}
return swallowed;
}
const entryKey = (file, snippet) => `${file} :: ${snippet}`;
/**
* Compare live swallowed catches against the frozen allowlist.
* @returns {{ violations: string[], stale: string[] }}
*/
export function evaluateSwallowedCatches(files, frozenEntries = []) {
const allowed = new Map();
for (const entry of frozenEntries) {
allowed.set(entryKey(entry.file, entry.snippet), entry);
}
const live = new Map();
for (const hit of collectSwallowedCatches(files)) {
const key = entryKey(hit.file, hit.snippet);
if (!live.has(key)) live.set(key, []);
live.get(key).push(hit);
}
const violations = [];
for (const [key, hits] of live) {
const entry = allowed.get(key);
const frozenCount = entry ? Number(entry.count ?? 1) : 0;
if (entry && !String(entry.reason ?? "").trim()) {
violations.push(`${hits[0].file}:${hits[0].line} :: swallowed-catch :: entry needs a reason`);
}
for (const hit of hits.slice(frozenCount)) {
violations.push(
`${hit.file}:${hit.line} :: swallowed-catch :: add 'throw' or '// no-effect: <motif>'` +
(hit.snippet ? ` (body: ${hit.snippet})` : " (empty body)")
);
}
}
const stale = [];
for (const [key, entry] of allowed) {
const liveCount = live.get(key)?.length ?? 0;
const frozenCount = Number(entry.count ?? 1);
if (liveCount < frozenCount) {
stale.push(`${key} (frozen ${frozenCount}, live ${liveCount})`);
}
}
return { violations, stale };
}
/**
* Rule B. An allowlist entry covers a `void (async` site only when its anchor appears
* within VOID_ASYNC_ANCHOR_WINDOW lines of that site in the same file.
* @returns {{ violations: string[], stale: string[] }}
*/
export function evaluateVoidAsyncSites(files, allowlist = []) {
const violations = [];
const used = new Set();
for (const { path: rel, source } of files) {
const lines = source.split("\n");
for (let i = 0; i < lines.length; i++) {
if (!VOID_ASYNC_PATTERN.test(lines[i])) continue;
const window = lines.slice(i, i + VOID_ASYNC_ANCHOR_WINDOW).join("\n");
const entry = allowlist.find(
(candidate) => candidate.file === rel && window.includes(candidate.anchor)
);
if (!entry) {
violations.push(
`${rel}:${i + 1} :: void-async :: await the async work, attach a .catch, or add an allowlist entry`
);
continue;
}
used.add(entry);
if (!String(entry.reason ?? "").trim()) {
violations.push(`${rel}:${i + 1} :: void-async :: allowlist entry needs a reason`);
}
}
}
const stale = allowlist
.filter((entry) => !used.has(entry))
.map((entry) => `${entry.file} :: ${entry.anchor}`);
return { violations, stale };
}
function loadEntries(allowlistPath) {
const raw = JSON.parse(fs.readFileSync(allowlistPath, "utf8"));
return raw.entries ?? raw;
}
function collectFiles() {
const files = [];
const push = (p) => {
files.push({
path: path.relative(cwd, p).replace(/\\/g, "/"),
source: fs.readFileSync(p, "utf8"),
});
};
for (const file of SCOPE_FILES) {
if (fs.existsSync(file)) push(file);
}
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) walk(p);
else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) push(p);
}
};
for (const dir of SCOPE_DIRS) walk(dir);
return files;
}
function main() {
const files = collectFiles();
const catchEntries = loadEntries(SWALLOWED_CATCH_ALLOWLIST_PATH);
const voidEntries = loadEntries(VOID_ASYNC_ALLOWLIST_PATH);
const catches = evaluateSwallowedCatches(files, catchEntries);
const voids = evaluateVoidAsyncSites(files, voidEntries);
const violations = [...catches.violations, ...voids.violations];
const stale = [...catches.stale, ...voids.stale];
if (violations.length) {
console.error(
`[check-routing-error-guard] ${violations.length} violation(s) on routing paths:\n` +
violations.map((v) => `${v}`).join("\n")
);
}
if (stale.length) {
console.error(
`[check-routing-error-guard] ${stale.length} stale allowlist entr(y/ies) — the site was fixed or changed; shrink or remove the entry:\n` +
stale.map((s) => `${s}`).join("\n")
);
}
if (violations.length || stale.length) {
process.exitCode = 1;
return;
}
console.log(
`[check-routing-error-guard] OK (${files.length} files scanned, ${catchEntries.length} frozen catch entries, ${voidEntries.length} void-async entries)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -82,6 +82,7 @@ import {
normalizeIntelligentRoutingConfig,
} from "@/lib/combos/intelligentRouting";
import { getComboStepTarget } from "@/lib/combos/steps";
import { DEAD_COMBO_CONFIG_KEYS } from "@/lib/combos/deadConfigKeys";
import { resolveServerErrorMessage } from "@/lib/api/serverErrorMessage";
import { useTranslations } from "next-intl";
@@ -217,23 +218,13 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
"What to do when the next combo target cannot accept the original reasoning transport. Drop is the default: it removes reasoning state and tries the target. Skip leaves the request body untouched and falls through.",
};
const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
// UI-only keys the modal manages itself (never persisted by this path):
// timeoutMs, healthCheckEnabled, healthCheckTimeoutMs.
const NON_PERSISTED_COMBO_CONFIG_KEYS = new Set([
...DEAD_COMBO_CONFIG_KEYS,
"timeoutMs",
"healthCheckEnabled",
"healthCheckTimeoutMs",
"queueTimeoutMs",
"queueDepth",
"fallbackDelayMs",
"handoffProviders",
"maxComboDepth",
"manifestRouting",
"complexityAwareRouting",
"pipeline_enabled",
"pipelineConcurrency",
"shadowRouting",
"evalRouting",
"resetAwareEnabled",
"resetAwareWindow",
]);
const MS_PER_SECOND = 1000;
@@ -255,7 +246,7 @@ function sanitizeComboRuntimeConfig(config) {
return Object.fromEntries(
Object.entries(config).filter(
([key, value]) =>
value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key)
value !== undefined && value !== null && !NON_PERSISTED_COMBO_CONFIG_KEYS.has(key)
)
);
}

View File

@@ -22,7 +22,8 @@ import {
type CompatModelRow,
} from "../providerPageHelpers";
import { ModelVisibilityToolbar } from "./ModelRow";
import { sortModelsFreeFirst, isFreeModel } from "@/shared/utils/freeModels";
import { sortModelsFreeFirst, isModelFreeBadge } from "@/shared/utils/freeModels";
import { useStrictFreeBadge } from "./useStrictFreeBadge";
import PassthroughModelRow, { type PassthroughModelRowProps } from "./PassthroughModelRow";
// ---------------------------------------------------------------------------
@@ -127,6 +128,7 @@ export default function CompatibleModelsSection({
const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all");
const [sortFreeFirst, setSortFreeFirst] = useState(false);
const notify = useNotificationStore();
const strictFreeBadge = useStrictFreeBadge();
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const providerAliases = useMemo(
@@ -164,11 +166,16 @@ export default function CompatibleModelsSection({
alias: aliasByModelId.get(model.id) || null,
displayName: model.name || model.id,
source,
isFree:
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") ||
isFreeModel(providerStorageAlias, { id: model.id, isFree: (model as any).isFree }),
isFree: isModelFreeBadge(
providerStorageAlias,
{
id: model.id,
name: model.name,
free: (model as { free?: unknown }).free,
isFree: model.isFree,
},
{ strict: strictFreeBadge }
),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
@@ -201,11 +208,16 @@ export default function CompatibleModelsSection({
alias: displayAlias,
displayName: displayAlias,
source: customModel ? customModel.source || "custom" : "alias",
isFree:
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || "") ||
isFreeModel(providerStorageAlias, { id: modelId, isFree: (customModel as any)?.isFree }),
isFree: isModelFreeBadge(
providerStorageAlias,
{
id: modelId,
name: customModel?.name || (alias as string) || "",
free: (customModel as { free?: unknown } | undefined)?.free,
isFree: customModel?.isFree,
},
{ strict: strictFreeBadge }
),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
@@ -220,6 +232,7 @@ export default function CompatibleModelsSection({
isModelHidden,
providerAliases,
providerStorageAlias,
strictFreeBadge,
]);
const filteredModels = allModels.filter((model) => {

View File

@@ -32,7 +32,8 @@ import {
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { ModelVisibilityToolbar } from "./ModelRow";
import { sortModelsFreeFirst, isFreeModel } from "@/shared/utils/freeModels";
import { sortModelsFreeFirst, isModelFreeBadge } from "@/shared/utils/freeModels";
import { useStrictFreeBadge } from "./useStrictFreeBadge";
import PassthroughModelRow from "./PassthroughModelRow";
// ---------------------------------------------------------------------------
@@ -138,6 +139,7 @@ export default function PassthroughModelsSection({
const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all");
const [sortFreeFirst, setSortFreeFirst] = useState(false);
const notify = useNotificationStore();
const strictFreeBadge = useStrictFreeBadge();
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const handleTestAll = async () => {
@@ -254,11 +256,16 @@ export default function PassthroughModelsSection({
alias: aliasByModelId.get(model.id) || defaultAlias,
displayName: model.name || model.id,
source,
isFree:
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") ||
isFreeModel(providerId, { id: model.id, isFree: (model as any).isFree }),
isFree: isModelFreeBadge(
providerId,
{
id: model.id,
name: model.name,
free: (model as { free?: unknown }).free,
isFree: model.isFree,
},
{ strict: strictFreeBadge }
),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
@@ -292,11 +299,16 @@ export default function PassthroughModelsSection({
alias: displayAlias,
displayName: displayAlias,
source: customModel ? customModel.source || "custom" : "alias",
isFree:
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || "") ||
isFreeModel(providerId, { id: modelId, isFree: (customModel as any)?.isFree }),
isFree: isModelFreeBadge(
providerId,
{
id: modelId,
name: customModel?.name || (alias as string) || "",
free: (customModel as { free?: unknown } | undefined)?.free,
isFree: customModel?.isFree,
},
{ strict: strictFreeBadge }
),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
@@ -312,6 +324,7 @@ export default function PassthroughModelsSection({
providerAlias,
providerAliases,
providerId,
strictFreeBadge,
]);
const filteredModels = allModels.filter((model) => {

View File

@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { FREE_BADGE_STRICT_FLAG } from "@/shared/utils/freeModels";
type FlagEntry = { key?: unknown; effectiveValue?: unknown };
/**
* Reads the FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER feature flag for the provider-page
* model lists. Fails closed: until the flag is loaded, and on any error, it returns
* false — the historical badge rule.
*/
export function useStrictFreeBadge(): boolean {
const [strict, setStrict] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch("/api/settings/feature-flags");
if (!res.ok) return;
const data = (await res.json()) as { flags?: FlagEntry[] };
const entry = Array.isArray(data?.flags)
? data.flags.find((flag) => flag?.key === FREE_BADGE_STRICT_FLAG)
: undefined;
const value = String(entry?.effectiveValue ?? "").toLowerCase();
if (!cancelled) setStrict(value === "true" || value === "1" || value === "on");
} catch {
// Keep the historical rule.
}
})();
return () => {
cancelled = true;
};
}, []);
return strict;
}

View File

@@ -581,7 +581,7 @@ import {
password: entry.password || undefined,
region: entry.region || null,
notes: entry.notes || null,
status: entry.status as "active" | "inactive",
status: entry.status as "active" | "inactive" | undefined,
})),
};
@@ -1413,13 +1413,13 @@ import {
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
<td className="py-1 px-2">
<td className="py-1 px-2 text-text-muted">
<span
className={
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
}
className={entry.status === "active" ? "text-emerald-400" : undefined}
>
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
{entry.status === "active" && t("statusActive")}
{entry.status === "inactive" && t("statusInactive")}
{!entry.status && "—"}
</span>
</td>
</tr>

View File

@@ -28,7 +28,8 @@ export type ParsedProxyEntry = {
password: string;
type: string;
region: string;
status: string;
/** Absent when the line carries no status: the import then leaves the stored one alone. */
status?: string;
notes: string;
};
@@ -90,7 +91,6 @@ function pushShorthandEntry(
password,
type: normalizedType,
region: "",
status: "active",
notes: "",
});
return true;
@@ -236,8 +236,8 @@ export function parseBulkImportText(text: string): {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
continue;
}
const normalizedStatus = (status || "active").toLowerCase();
if (!VALID_PROXY_STATUSES[normalizedStatus]) {
const normalizedStatus = status ? status.toLowerCase() : undefined;
if (normalizedStatus !== undefined && !VALID_PROXY_STATUSES[normalizedStatus]) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
continue;
}
@@ -250,7 +250,7 @@ export function parseBulkImportText(text: string): {
password: password || "",
type: normalizedType,
region: region || "",
status: normalizedStatus,
...(normalizedStatus ? { status: normalizedStatus } : {}),
notes: notes || "",
});
continue;

View File

@@ -14,6 +14,7 @@ import { QUOTA_MODEL_PREFIX } from "@/lib/quota/quotaModelNaming";
import { comboErrorResponse } from "@/lib/api/comboErrorResponse";
import { ComboInvariantError } from "@/lib/combos/invariants";
import { buildComboNameCollisionWarning } from "@/lib/combos/modelNameCollision";
import { stripDeadComboConfigKeys } from "@/lib/combos/deadConfigKeys";
// Minimal shape for the fields we read off a combo row in this route.
// `getComboById` returns a structurally `JsonRecord`-typed object, so we
@@ -32,47 +33,6 @@ type ComboRowShape = {
context_length?: number | null;
};
/**
* Keys that were present in older combo configs (≤ v3.8.31) but have since been
* removed from comboRuntimeConfigSchema. The dashboard modal sanitises the three
* UI-level keys (timeoutMs, healthCheckEnabled, healthCheckTimeoutMs) before PUT,
* but v3.8.31-era stored configs also carry these 12 keys which were spread back
* into the body on edit+save. We strip them server-side so removed keys don't
* accumulate in `combos.data` and so the next read produces a clean config.
*
* Idempotent — running twice is a no-op.
*/
const LEGACY_REMOVED_COMBO_CONFIG_KEYS = Object.freeze([
"queueDepth",
"fallbackDelayMs",
"handoffProviders",
"maxComboDepth",
"manifestRouting",
"complexityAwareRouting",
"pipeline_enabled",
"pipelineConcurrency",
"shadowRouting",
"evalRouting",
"resetAwareEnabled",
"resetAwareWindow",
]);
function stripLegacyComboConfigKeys(rawConfig) {
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
return rawConfig;
}
let mutated = false;
const next = {};
for (const [key, value] of Object.entries(rawConfig)) {
if (LEGACY_REMOVED_COMBO_CONFIG_KEYS.includes(key)) {
mutated = true;
continue;
}
next[key] = value;
}
return mutated ? next : rawConfig;
}
// GET /api/combos/[id] - Get combo by ID
export async function GET(request, { params }) {
const authError = await requireManagementAuth(request);
@@ -161,7 +121,7 @@ export async function PUT(request, { params }) {
delete normalizedUpdate.compressionOverride;
}
if (normalizedUpdate.config && typeof normalizedUpdate.config === "object") {
normalizedUpdate.config = stripLegacyComboConfigKeys(normalizedUpdate.config);
normalizedUpdate.config = stripDeadComboConfigKeys(normalizedUpdate.config);
}
const body = normalizedUpdate.models

View File

@@ -13,6 +13,7 @@ import { comboErrorResponse } from "@/lib/api/comboErrorResponse";
import { computeComboContextLength } from "@/lib/combos/comboContext";
import { ComboInvariantError } from "@/lib/combos/invariants";
import { buildComboNameCollisionWarning } from "@/lib/combos/modelNameCollision";
import { stripDeadComboConfigKeys } from "@/lib/combos/deadConfigKeys";
// GET /api/combos - Get all combos
export async function GET(request: Request) {
@@ -70,6 +71,9 @@ export async function POST(request) {
...validation.data,
models: normalizedModels,
};
if (comboInput.config && typeof comboInput.config === "object") {
comboInput.config = stripDeadComboConfigKeys(comboInput.config);
}
const { name, strategy, config } = comboInput;
const compositeValidation = validateCompositeTiersConfig(comboInput);
if (compositeValidation.success === false) {

View File

@@ -53,7 +53,8 @@ function buildComboTestResult(
async function testComboTarget(
target: ResolvedComboTarget,
baseInternalUrl: string,
internalApiKey: string | null
internalApiKey: string | null,
parentSignal: AbortSignal | null = null
) {
const startTime = Date.now();
try {
@@ -79,6 +80,9 @@ async function testComboTarget(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), COMBO_TEST_TIMEOUT_MS);
const combinedSignal = parentSignal
? AbortSignal.any([parentSignal, controller.signal])
: controller.signal;
let res;
try {
@@ -95,7 +99,7 @@ async function testComboTarget(
"X-Request-Id": `combo-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal: controller.signal,
signal: combinedSignal,
});
} finally {
clearTimeout(timeout);
@@ -140,12 +144,20 @@ async function testComboTarget(
});
} catch (error) {
const latencyMs = Date.now() - startTime;
const err = error as Error;
let errorMessage: string;
if (err.name === "AbortError") {
// Parent abort wins over timer expiry: retrying is pointless once the client is gone.
errorMessage =
parentSignal?.aborted === true
? sanitizeErrorMessage("Client disconnected")
: `Timeout (${COMBO_TEST_TIMEOUT_MS / 1000}s)`;
} else {
errorMessage = sanitizeErrorMessage(err.message);
}
return buildComboTestResult(target, {
status: "error",
error:
error.name === "AbortError"
? `Timeout (${COMBO_TEST_TIMEOUT_MS / 1000}s)`
: sanitizeErrorMessage(error.message),
error: errorMessage,
latencyMs,
});
}
@@ -199,6 +211,11 @@ export async function POST(request) {
const results: ComboTestResult[] = [];
const loopStarted = Date.now();
for (const target of targets) {
// Client disconnects surface through request.signal (passed as
// parentSignal at the call site below). Stop instead of starting another doomed probe.
if (request.signal?.aborted) {
break;
}
if (Date.now() - loopStarted >= COMBO_TEST_TOTAL_TIMEOUT_MS) {
results.push(
buildComboTestResult(target, {
@@ -209,7 +226,7 @@ export async function POST(request) {
);
continue;
}
results.push(await testComboTarget(target, baseInternalUrl, internalApiKey));
results.push(await testComboTarget(target, baseInternalUrl, internalApiKey, request.signal));
}
const resolvedResult = results.find((result) => result.status === "ok") || null;
const resolvedBy = resolvedResult?.model || null;

View File

@@ -1,3 +1,4 @@
import { isSocks5ProxyEnabled } from "@omniroute/open-sse/utils/proxyDispatcher";
import { listProxies } from "@/lib/db/proxies";
import {
handleProxyCreate,
@@ -42,10 +43,8 @@ export async function GET(request: Request) {
// #5890: coarse relay health pulse for the dashboard — how many relay
// probes have run, and how many came back alive.
relayProbeStats: getRelayProbeStats(),
// Default ON (opt-out): only an explicit falsey value disables SOCKS5.
socks5Enabled: !["false", "0", "no", "off"].includes(
(process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
),
// SOCKS5 defaults ON — see isSocks5ProxyEnabled().
socks5Enabled: isSocks5ProxyEnabled(),
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");

View File

@@ -6,7 +6,10 @@ import {
resolveProxyForConnection,
} from "@/lib/db/settings";
import { getProxyAssignments, getProxyById } from "@/lib/db/proxies";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import {
clearDispatcherCache,
isSocks5ProxyEnabled,
} from "@omniroute/open-sse/utils/proxyDispatcher";
import { updateProxyConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
@@ -29,21 +32,15 @@ const PROXY_LEVEL_TO_REGISTRY_SCOPE = {
key: "account",
} as const;
function isSocks5Enabled() {
// Default ON (opt-out): only an explicit falsey value disables SOCKS5.
const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase();
return !["false", "0", "no", "off"].includes(raw);
}
function getSupportedProxyTypes() {
if (isSocks5Enabled()) {
if (isSocks5ProxyEnabled()) {
return new Set([...BASE_SUPPORTED_PROXY_TYPES, "socks5"]);
}
return BASE_SUPPORTED_PROXY_TYPES;
}
function supportedTypesMessage() {
return isSocks5Enabled() ? "http, https, or socks5" : "http or https";
return isSocks5ProxyEnabled() ? "http, https, or socks5" : "http or https";
}
function createInvalidProxyError(message: string): ApiRouteError {
@@ -104,7 +101,7 @@ function normalizeAndValidateProxy(
}
const type = String(proxy.type || "http").toLowerCase() as NonNullable<ProxyConfigInput["type"]>;
if (type === "socks5" && !isSocks5Enabled()) {
if (type === "socks5" && !isSocks5ProxyEnabled()) {
throw createInvalidProxyError(
"SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
);

View File

@@ -1,6 +1,9 @@
import { NextResponse } from "next/server";
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
import { extractApiKey } from "@/sse/services/auth";
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import { CORS_HEADERS } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
export interface ApiKeyRequestScope {
apiKey: string | null;
@@ -26,3 +29,78 @@ export async function getApiKeyRequestScope(request: Request): Promise<ApiKeyReq
isSessionAuth,
};
}
/**
* Canonical per-record ownership check for API-key-scoped resources (files,
* batches, …). Three callers, three answers — the same model as the sweep
* scoping in `deleteCompletedBatches()` (GHSA-wvxc-jp3v-5mg5):
*
* - the operator's own dashboard (session auth) may act on ANY record;
* - an API key may act on its OWN records only;
* - a record with no owner (null/undefined `api_key_id`) is unattributable
* and is denied to every non-session caller — an anonymous request and a
* foreign key alike. "No owner" is NOT "no restriction": every file/batch
* row has carried `api_key_id` since the table was created, so a null
* owner is an anonymous or dashboard-session write (or a batch artifact
* inheriting one), and letting any principal read, download or delete it
* was GHSA-2jm2-mpx8-6523.
*
* `getApiKeyRequestScope` never sets `rejection` — with `REQUIRE_API_KEY=false`
* the central policy admits both a missing and an invalid bearer as anonymous —
* so `{ apiKeyId: null, isSessionAuth: false }` is exactly the anonymous shape
* and must never match a record.
*/
export function canAccessOwnedRecord(
scope: Pick<ApiKeyRequestScope, "isSessionAuth" | "apiKeyId">,
recordApiKeyId: string | null | undefined
): boolean {
if (scope.isSessionAuth) return true;
if (recordApiKeyId === null || recordApiKeyId === undefined) return false;
return recordApiKeyId === scope.apiKeyId;
}
/**
* Owner scope of a CLIENT_API list/count read (`GET /v1/files`, `GET /v1/batches`).
* The intent is explicit on purpose, exactly like the `delete-completed` sweep:
* a caller is either scoped to the API key it presented, or it is the operator's
* dashboard session reading the whole instance, or it is rejected — there is no
* default that widens a read to every tenant (GHSA-m3hp-hq9g-fpmv).
*/
export type OwnedListScope =
| { mode: "api_key"; apiKeyId: string }
| { mode: "instance" }
| { mode: "rejected"; response: Response };
function unauthorized(message: string): Response {
return NextResponse.json(buildErrorBody(401, message), { status: 401, headers: CORS_HEADERS });
}
/**
* Resolve the {@link OwnedListScope} of a list/count request, failing closed:
*
* - a presented bearer that does not resolve to a key row (deleted, rotated,
* mistyped) → 401 "Invalid API key" — even when a session cookie is also
* present, so an unresolvable key never falls through to the session branch;
* - a resolved key → scoped to that key, even alongside a session cookie (the
* key wins, so a leaked or over-shared key can never widen a read);
* - a dashboard session WITHOUT a key → instance-wide (the operator's own
* dashboard is the one legitimate instance-wide reader);
* - anything else (anonymous under `REQUIRE_API_KEY=false`) → 401
* "Authentication required".
*
* The list handlers used to coerce `apiKeyId || undefined`, and the DB layer
* reads `undefined` as "no owner filter" — so the anonymous caller landed in the
* same unfiltered bucket as the operator.
*/
export function resolveListScope(scope: ApiKeyRequestScope): OwnedListScope {
if (scope.apiKey && !scope.apiKeyId) {
return { mode: "rejected", response: unauthorized("Invalid API key") };
}
if (scope.apiKeyId) {
return { mode: "api_key", apiKeyId: scope.apiKeyId };
}
if (scope.isSessionAuth) {
return { mode: "instance" };
}
return { mode: "rejected", response: unauthorized("Authentication required") };
}

View File

@@ -1,7 +1,7 @@
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getBatch, updateBatch } from "@/lib/db/batches";
import { NextResponse } from "next/server";
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope";
import { formatBatchResponse } from "../../formatBatchResponse";
export async function OPTIONS() {
@@ -11,12 +11,15 @@ export async function OPTIONS() {
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
const apiKeyId = scope.apiKeyId;
const { id } = await params;
const batch = getBatch(id);
if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) {
// The shared 3-way rule: the operator's dashboard (session auth) may cancel
// ANY batch — the old inline check 404'd every dashboard cancel of a
// key-owned batch (#13683) — a key cancels its own, and a null-owner batch
// is denied to a foreign key and to an anonymous caller (GHSA-2jm2-mpx8-6523).
if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) {
return NextResponse.json(
{ error: { message: "Batch not found", type: "invalid_request_error" } },
{ status: 404, headers: CORS_HEADERS }

Some files were not shown because too many files have changed in this diff Show More