diff --git a/.github/workflows/radar-export.yml b/.github/workflows/radar-export.yml index 043de88d3d..18f29e4d60 100644 --- a/.github/workflows/radar-export.yml +++ b/.github/workflows/radar-export.yml @@ -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 diff --git a/@omniroute/opencode-plugin-v2/README.md b/@omniroute/opencode-plugin-v2/README.md index 873290a9d1..00b9cd4ad9 100644 --- a/@omniroute/opencode-plugin-v2/README.md +++ b/@omniroute/opencode-plugin-v2/README.md @@ -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 `/…` | -| `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 `/…` | +| `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 diff --git a/@omniroute/opencode-plugin-v2/src/cache.ts b/@omniroute/opencode-plugin-v2/src/cache.ts index 0f7ad948bd..b6a950449c 100644 --- a/@omniroute/opencode-plugin-v2/src/cache.ts +++ b/@omniroute/opencode-plugin-v2/src/cache.ts @@ -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 { + // 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). + } } } diff --git a/@omniroute/opencode-plugin-v2/src/index.ts b/@omniroute/opencode-plugin-v2/src/index.ts index f6c0471baf..7e29751837 100644 --- a/@omniroute/opencode-plugin-v2/src/index.ts +++ b/@omniroute/opencode-plugin-v2/src/index.ts @@ -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 diff --git a/@omniroute/opencode-plugin-v2/src/options.ts b/@omniroute/opencode-plugin-v2/src/options.ts index 9f9f23041c..b0f5743697 100644 --- a/@omniroute/opencode-plugin-v2/src/options.ts +++ b/@omniroute/opencode-plugin-v2/src/options.ts @@ -61,6 +61,21 @@ const pluginOptionsSchema = z export type PluginOptions = z.infer; +/** 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. */ diff --git a/@omniroute/opencode-plugin-v2/tests/disk-snapshot-atomicity.test.ts b/@omniroute/opencode-plugin-v2/tests/disk-snapshot-atomicity.test.ts new file mode 100644 index 0000000000..042cbda3b5 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/tests/disk-snapshot-atomicity.test.ts @@ -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 }); + } + }); +}); diff --git a/@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts b/@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts new file mode 100644 index 0000000000..d8e4171d66 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts @@ -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; + warns: string[]; + restore: () => void; +} + +function installHarness(combos: unknown[]): Harness { + const seen = new Map(); + 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 }) => { + 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( + mgmt: string | undefined, + inference: string | undefined, + fn: () => Promise +): Promise { + 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) { + const catalogCallbacks: Array<(draft: unknown) => Promise> = []; + const ctx = { + options, + catalog: { + transform: (cb: (draft: unknown) => Promise) => { + catalogCallbacks.push(cb); + return Promise.resolve({ dispose: async () => {} }); + }, + }, + integration: { + transform: () => Promise.resolve({ dispose: async () => {} }), + }, + }; + return { catalogCallbacks, ctx }; +} + +function stubDraft() { + const published = new Map>(); + const draft = { + provider: { update: (_id: string, fn: (p: Record) => void) => fn({}) }, + model: { + update: (pid: string, mid: string, fn: (m: Record) => 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 { + await (plugin as unknown as { setup: (ctx: unknown) => Promise }).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(); + const models = new Map(); + 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"); + }); +}); diff --git a/changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md b/changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md new file mode 100644 index 0000000000..a6f4d3f3b9 --- /dev/null +++ b/changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md @@ -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 diff --git a/changelog.d/fixes/13217-combo-dead-keys.md b/changelog.d/fixes/13217-combo-dead-keys.md new file mode 100644 index 0000000000..f9260cd9be --- /dev/null +++ b/changelog.d/fixes/13217-combo-dead-keys.md @@ -0,0 +1 @@ +- **fix(combos):** stop dropping live keys and persisting dead ones ([#13217](https://github.com/diegosouzapw/OmniRoute/pull/13217)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13218-wal-busy-counter.md b/changelog.d/fixes/13218-wal-busy-counter.md new file mode 100644 index 0000000000..7b82d5a484 --- /dev/null +++ b/changelog.d/fixes/13218-wal-busy-counter.md @@ -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 diff --git a/changelog.d/fixes/13279-combo-test-admission.md b/changelog.d/fixes/13279-combo-test-admission.md new file mode 100644 index 0000000000..d138890556 --- /dev/null +++ b/changelog.d/fixes/13279-combo-test-admission.md @@ -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 diff --git a/changelog.d/fixes/13280-bounded-routing-caches.md b/changelog.d/fixes/13280-bounded-routing-caches.md new file mode 100644 index 0000000000..639f25ecce --- /dev/null +++ b/changelog.d/fixes/13280-bounded-routing-caches.md @@ -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 diff --git a/changelog.d/fixes/13281-error-type-contract.md b/changelog.d/fixes/13281-error-type-contract.md new file mode 100644 index 0000000000..30dc309a19 --- /dev/null +++ b/changelog.d/fixes/13281-error-type-contract.md @@ -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 diff --git a/changelog.d/fixes/13436-client-bundle-server-only-guard.md b/changelog.d/fixes/13436-client-bundle-server-only-guard.md new file mode 100644 index 0000000000..7b8cf30097 --- /dev/null +++ b/changelog.d/fixes/13436-client-bundle-server-only-guard.md @@ -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 diff --git a/changelog.d/fixes/13438-cold-catalog-retry.md b/changelog.d/fixes/13438-cold-catalog-retry.md new file mode 100644 index 0000000000..c03bb7ffb9 --- /dev/null +++ b/changelog.d/fixes/13438-cold-catalog-retry.md @@ -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 diff --git a/changelog.d/fixes/13439-protected-priority-502.md b/changelog.d/fixes/13439-protected-priority-502.md new file mode 100644 index 0000000000..8d32643b8b --- /dev/null +++ b/changelog.d/fixes/13439-protected-priority-502.md @@ -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 diff --git a/changelog.d/fixes/13440-daily-reset-tz.md b/changelog.d/fixes/13440-daily-reset-tz.md new file mode 100644 index 0000000000..7211394453 --- /dev/null +++ b/changelog.d/fixes/13440-daily-reset-tz.md @@ -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 diff --git a/changelog.d/fixes/13441-error-type-write-guard.md b/changelog.d/fixes/13441-error-type-write-guard.md new file mode 100644 index 0000000000..f7d5f763e1 --- /dev/null +++ b/changelog.d/fixes/13441-error-type-write-guard.md @@ -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 diff --git a/changelog.d/fixes/13471-opencode-muse-spark-1-3-responses.md b/changelog.d/fixes/13471-opencode-muse-spark-1-3-responses.md new file mode 100644 index 0000000000..14aeff3b5d --- /dev/null +++ b/changelog.d/fixes/13471-opencode-muse-spark-1-3-responses.md @@ -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) diff --git a/changelog.d/fixes/13577-proxy-status-preserved-on-write.md b/changelog.d/fixes/13577-proxy-status-preserved-on-write.md new file mode 100644 index 0000000000..3e5dc7a37f --- /dev/null +++ b/changelog.d/fixes/13577-proxy-status-preserved-on-write.md @@ -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 diff --git a/changelog.d/fixes/13582-partial-update-keeps-omitted-fields.md b/changelog.d/fixes/13582-partial-update-keeps-omitted-fields.md new file mode 100644 index 0000000000..e352125718 --- /dev/null +++ b/changelog.d/fixes/13582-partial-update-keeps-omitted-fields.md @@ -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 diff --git a/changelog.d/fixes/13605-socks-userinfo-decode-guard.md b/changelog.d/fixes/13605-socks-userinfo-decode-guard.md new file mode 100644 index 0000000000..34d94231bc --- /dev/null +++ b/changelog.d/fixes/13605-socks-userinfo-decode-guard.md @@ -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 diff --git a/changelog.d/fixes/13606-token-limit-request-scoped.md b/changelog.d/fixes/13606-token-limit-request-scoped.md new file mode 100644 index 0000000000..fff9e1bcce --- /dev/null +++ b/changelog.d/fixes/13606-token-limit-request-scoped.md @@ -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 diff --git a/changelog.d/fixes/13607-disk-snapshot-atomic-write.md b/changelog.d/fixes/13607-disk-snapshot-atomic-write.md new file mode 100644 index 0000000000..e816ccfcd2 --- /dev/null +++ b/changelog.d/fixes/13607-disk-snapshot-atomic-write.md @@ -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 diff --git a/changelog.d/fixes/13612-validate-pool-preserve-status.md b/changelog.d/fixes/13612-validate-pool-preserve-status.md new file mode 100644 index 0000000000..3198561fc0 --- /dev/null +++ b/changelog.d/fixes/13612-validate-pool-preserve-status.md @@ -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 diff --git a/changelog.d/fixes/13613-management-token-env.md b/changelog.d/fixes/13613-management-token-env.md new file mode 100644 index 0000000000..10496cd792 --- /dev/null +++ b/changelog.d/fixes/13613-management-token-env.md @@ -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 diff --git a/changelog.d/fixes/13614-routing-error-guard.md b/changelog.d/fixes/13614-routing-error-guard.md new file mode 100644 index 0000000000..5a8818ea01 --- /dev/null +++ b/changelog.d/fixes/13614-routing-error-guard.md @@ -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 diff --git a/changelog.d/fixes/13633-stream-recovery-toolcall-order.md b/changelog.d/fixes/13633-stream-recovery-toolcall-order.md new file mode 100644 index 0000000000..acbac114e0 --- /dev/null +++ b/changelog.d/fixes/13633-stream-recovery-toolcall-order.md @@ -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 diff --git a/changelog.d/fixes/13641-search-ghost.md b/changelog.d/fixes/13641-search-ghost.md new file mode 100644 index 0000000000..3623f9d9a7 --- /dev/null +++ b/changelog.d/fixes/13641-search-ghost.md @@ -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 diff --git a/changelog.d/fixes/13645-free-badge-auth.md b/changelog.d/fixes/13645-free-badge-auth.md new file mode 100644 index 0000000000..01c0b1e43b --- /dev/null +++ b/changelog.d/fixes/13645-free-badge-auth.md @@ -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 diff --git a/changelog.d/fixes/13646-socks-flag-reader.md b/changelog.d/fixes/13646-socks-flag-reader.md new file mode 100644 index 0000000000..af2032e4f8 --- /dev/null +++ b/changelog.d/fixes/13646-socks-flag-reader.md @@ -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 diff --git a/changelog.d/fixes/13650-recovery-trace-logging.md b/changelog.d/fixes/13650-recovery-trace-logging.md new file mode 100644 index 0000000000..9a4ccf8b70 --- /dev/null +++ b/changelog.d/fixes/13650-recovery-trace-logging.md @@ -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 diff --git a/changelog.d/fixes/13671-dst-gap.md b/changelog.d/fixes/13671-dst-gap.md new file mode 100644 index 0000000000..cec8fa3160 --- /dev/null +++ b/changelog.d/fixes/13671-dst-gap.md @@ -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 diff --git a/changelog.d/fixes/13672-retry-after-provenance.md b/changelog.d/fixes/13672-retry-after-provenance.md new file mode 100644 index 0000000000..4006b2c3f4 --- /dev/null +++ b/changelog.d/fixes/13672-retry-after-provenance.md @@ -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 diff --git a/changelog.d/fixes/13686-estimated-usage-guard.md b/changelog.d/fixes/13686-estimated-usage-guard.md new file mode 100644 index 0000000000..cb8cfbd2e0 --- /dev/null +++ b/changelog.d/fixes/13686-estimated-usage-guard.md @@ -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 diff --git a/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md b/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md new file mode 100644 index 0000000000..7835ca750a --- /dev/null +++ b/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md @@ -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) diff --git a/changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md b/changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md new file mode 100644 index 0000000000..cb74cb6fc4 --- /dev/null +++ b/changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md @@ -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. diff --git a/changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md b/changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md new file mode 100644 index 0000000000..d058d6fc58 --- /dev/null +++ b/changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md @@ -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) diff --git a/changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md b/changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md new file mode 100644 index 0000000000..00ae8cde35 --- /dev/null +++ b/changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md @@ -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. diff --git a/changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md b/changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md new file mode 100644 index 0000000000..8bf8330e4b --- /dev/null +++ b/changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md @@ -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/` diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index bd489ce752..5ffac46924 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index fef8ea6cfe..66cf67d436 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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).", diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7072b06824..5e49a388dc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 0c470b525c..18cc55cacf 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -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`. --- diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 65a5bcac12..1b7d4a1a58 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -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 / 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, diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index a6b89ac68d..15b25fba0f 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -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` diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index 2d364bd5fb..dcc428037f 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -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", diff --git a/open-sse/config/providers/registry/opencode/index.ts b/open-sse/config/providers/registry/opencode/index.ts index e402a6e9cf..b667331289 100644 --- a/open-sse/config/providers/registry/opencode/index.ts +++ b/open-sse/config/providers/registry/opencode/index.ts @@ -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, diff --git a/open-sse/config/providers/registry/opencode/zen/index.ts b/open-sse/config/providers/registry/opencode/zen/index.ts index 75849d65a6..1bb2382aad 100644 --- a/open-sse/config/providers/registry/opencode/zen/index.ts +++ b/open-sse/config/providers/registry/opencode/zen/index.ts @@ -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 diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 463c8bec65..ce8ea29b60 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -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 = { "deepseek-v4-pro": EFFORT_LEVELS, @@ -107,6 +109,7 @@ const EFFORT_TIERS: Record = { "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"], }; /** diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 9bcdccd34e..7ef9ef5541 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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?.( diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index f1ddcc1678..b434645595 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -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, diff --git a/open-sse/handlers/chatCore/quotaShareConsumption.ts b/open-sse/handlers/chatCore/quotaShareConsumption.ts index cb50ea2e22..70de289966 100644 --- a/open-sse/handlers/chatCore/quotaShareConsumption.ts +++ b/open-sse/handlers/chatCore/quotaShareConsumption.ts @@ -21,9 +21,8 @@ export async function scheduleQuotaShareConsumption(args: { }): Promise { 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, diff --git a/open-sse/handlers/chatCore/recoveryTraceLogging.ts b/open-sse/handlers/chatCore/recoveryTraceLogging.ts new file mode 100644 index 0000000000..6ead4fbfba --- /dev/null +++ b/open-sse/handlers/chatCore/recoveryTraceLogging.ts @@ -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 { + 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); + }, + }; +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 8c3596e14d..bd6f1b68aa 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -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 }]; } diff --git a/open-sse/handlers/imageUpscale/shared.ts b/open-sse/handlers/imageUpscale/shared.ts index cf37e99910..557274b88b 100644 --- a/open-sse/handlers/imageUpscale/shared.ts +++ b/open-sse/handlers/imageUpscale/shared.ts @@ -71,7 +71,9 @@ export function extractUpscaleSourceImage(body: unknown): string | null { if (!body || typeof body !== "object") return null; const b = body as Record; 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) : {}; @@ -161,7 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise= 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(() => {}); diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 789b1019ca..e0076112a9 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -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 diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 5d947d429a..818183e6b7 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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( // (`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()) { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index acc72de4ae..8b8da3a0e2 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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[] = [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 = { "grok-4-fast-non-reasoning": 1143, @@ -1081,4 +1038,3 @@ async function handleComboChatInner({ _unregisterExecutionCandidates(_registeredExecutionKeys); } } - diff --git a/open-sse/services/combo/comboDailyResetClock.ts b/open-sse/services/combo/comboDailyResetClock.ts new file mode 100644 index 0000000000..f514935b45 --- /dev/null +++ b/open-sse/services/combo/comboDailyResetClock.ts @@ -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 { + 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; + } +} diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 4a7b6a20e1..435c17458d 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -246,6 +246,7 @@ const REQUEST_SCOPED_UPSTREAM_ERROR_CODES: Record = { 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, diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index bd948cf535..2572dc2c20 100644 --- a/open-sse/services/combo/executeTargetAttempt.ts +++ b/open-sse/services/combo/executeTargetAttempt.ts @@ -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") diff --git a/open-sse/services/combo/executeTargetGates.ts b/open-sse/services/combo/executeTargetGates.ts index a649a54b50..4bf24601bb 100644 --- a/open-sse/services/combo/executeTargetGates.ts +++ b/open-sse/services/combo/executeTargetGates.ts @@ -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" + ), }; } diff --git a/open-sse/services/combo/protectedPriorityStopStatus.ts b/open-sse/services/combo/protectedPriorityStopStatus.ts new file mode 100644 index 0000000000..0396a8714c --- /dev/null +++ b/open-sse/services/combo/protectedPriorityStopStatus.ts @@ -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; + } +} diff --git a/open-sse/services/combo/roundRobinCombo.ts b/open-sse/services/combo/roundRobinCombo.ts index 2cc3c6895f..be973e57d0 100644 --- a/open-sse/services/combo/roundRobinCombo.ts +++ b/open-sse/services/combo/roundRobinCombo.ts @@ -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 = diff --git a/open-sse/services/combo/staleLkgpClear.ts b/open-sse/services/combo/staleLkgpClear.ts new file mode 100644 index 0000000000..5d9824b4a9 --- /dev/null +++ b/open-sse/services/combo/staleLkgpClear.ts @@ -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; + +async function clearPins( + comboName: string, + executionKey: string | null | undefined, + comboId: string | null | undefined, + clearLKGP: ClearLkgp | undefined +): Promise { + 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 { + 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, + }); + }); +} diff --git a/open-sse/services/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts index a5108d132f..fd9360d49b 100644 --- a/open-sse/services/dailyQuotaReset.ts +++ b/open-sse/services/dailyQuotaReset.ts @@ -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(); + +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 = {}; 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; +} diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 5e60017662..c72d8f1ce8 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -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; } diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index ab5a87a693..43ed09d68d 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -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[0], @@ -77,29 +75,19 @@ export function __resetGenericQuotaFetcherForTests(): void { pendingForceRefreshMiss.clear(); } -interface CacheEntry { - quota: QuotaInfo; - fetchedAt: number; -} - -const cache = new Map(); +// 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("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; }; diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index ec0080ef83..1d36623205 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -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>; type ModelAliasValue = string | { provider?: string; model?: string }; type ModelAliasMap = Record; @@ -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 = {}; -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/") 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/") -// 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(); - 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`. * diff --git a/open-sse/services/providerAlias.ts b/open-sse/services/providerAlias.ts new file mode 100644 index 0000000000..a5941b22e0 --- /dev/null +++ b/open-sse/services/providerAlias.ts @@ -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 = {}; +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/") 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/") +// 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(); + 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; +} diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 562aa3ec30..1546351865 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -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(); const connectionRateLimitOverrides = new Map>(); // Store learned limits for persistence (debounced) -const learnedLimits: Record = {}; -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("learned-limits", MAX_LEARNED_LIMITS, "lru"); const limiterLastUsed = new Map(); let persistTimer: ReturnType | null = null; const pendingAsyncOperations = new Set>(); @@ -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)) { diff --git a/open-sse/services/routing/quality.ts b/open-sse/services/routing/quality.ts index b4e06a9ed9..f24f2fd269 100644 --- a/open-sse/services/routing/quality.ts +++ b/open-sse/services/routing/quality.ts @@ -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(); +/** + * 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("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; diff --git a/open-sse/services/streamRecovery.ts b/open-sse/services/streamRecovery.ts index 3a95e9a2a3..3bd95e5123 100644 --- a/open-sse/services/streamRecovery.ts +++ b/open-sse/services/streamRecovery.ts @@ -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) => { 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 + controller: ReadableStreamDefaultController, + cut = true ): Promise => { - 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 | 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; diff --git a/open-sse/utils/credentialPatterns.ts b/open-sse/utils/credentialPatterns.ts index b9a2366d70..24083675c1 100644 --- a/open-sse/utils/credentialPatterns.ts +++ b/open-sse/utils/credentialPatterns.ts @@ -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: /(? 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) }), }, }); } diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index e557fe7f31..6d9c6714ae 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -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[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; } diff --git a/open-sse/utils/proxyFallback.ts b/open-sse/utils/proxyFallback.ts index 5590dbd16a..a2fc1550fe 100644 --- a/open-sse/utils/proxyFallback.ts +++ b/open-sse/utils/proxyFallback.ts @@ -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, diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index d71b858cc8..16339a95af 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -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(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) diff --git a/package-lock.json b/package-lock.json index 280d229a98..b5d62cb4e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index a91eaade9e..a42bf3a0cf 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/check/allowlist-routing-swallowed-catch.json b/scripts/check/allowlist-routing-swallowed-catch.json new file mode 100644 index 0000000000..5aa8719efe --- /dev/null +++ b/scripts/check/allowlist-routing-swallowed-catch.json @@ -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" + } + ] +} diff --git a/scripts/check/allowlist-void-async.json b/scripts/check/allowlist-void-async.json new file mode 100644 index 0000000000..ded2cd9f4b --- /dev/null +++ b/scripts/check/allowlist-void-async.json @@ -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" + } + ] +} diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts index 14e5968278..3122fd8ecd 100644 --- a/scripts/check/check-route-guard-membership.ts +++ b/scripts/check/check-route-guard-membership.ts @@ -53,6 +53,26 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray = [ "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 diff --git a/scripts/check/check-routing-error-guard.mjs b/scripts/check/check-routing-error-guard.mjs new file mode 100644 index 0000000000..b8769231e3 --- /dev/null +++ b/scripts/check/check-routing-error-guard.mjs @@ -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: ` 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: '` + + (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(); diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index c24d324540..1188ca837b 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -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) ) ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx index 45f0301c28..096dc23584 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -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) => { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index c8d181577d..94ca769c7b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -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) => { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts new file mode 100644 index 0000000000..f415188c8a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts @@ -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; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 990d17988d..3af5b91361 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -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 { {entry.port} {entry.username || "—"} {entry.region || "—"} - + - {entry.status === "active" ? t("statusActive") : t("statusInactive")} + {entry.status === "active" && t("statusActive")} + {entry.status === "inactive" && t("statusInactive")} + {!entry.status && "—"} diff --git a/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts b/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts index cbd70440a3..61b7b27ed0 100644 --- a/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts +++ b/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts @@ -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; diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index dd35562bdf..04f39a35b1 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -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 diff --git a/src/app/api/combos/route.ts b/src/app/api/combos/route.ts index 592cb8f65e..ba732383b1 100644 --- a/src/app/api/combos/route.ts +++ b/src/app/api/combos/route.ts @@ -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) { diff --git a/src/app/api/combos/test/route.ts b/src/app/api/combos/test/route.ts index 6d6851752e..7bd1df5067 100644 --- a/src/app/api/combos/test/route.ts +++ b/src/app/api/combos/test/route.ts @@ -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; diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index fe3153629d..35031d4aa4 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -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"); diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index d05d0eb4c2..01d07e6331 100755 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -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; - 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)" ); diff --git a/src/app/api/v1/_helpers/apiKeyScope.ts b/src/app/api/v1/_helpers/apiKeyScope.ts index 4d238a7818..21aba3fca9 100644 --- a/src/app/api/v1/_helpers/apiKeyScope.ts +++ b/src/app/api/v1/_helpers/apiKeyScope.ts @@ -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, + 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") }; +} diff --git a/src/app/api/v1/batches/[id]/cancel/route.ts b/src/app/api/v1/batches/[id]/cancel/route.ts index 3222f0f0d8..d441f9911c 100644 --- a/src/app/api/v1/batches/[id]/cancel/route.ts +++ b/src/app/api/v1/batches/[id]/cancel/route.ts @@ -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 } diff --git a/src/app/api/v1/batches/[id]/route.ts b/src/app/api/v1/batches/[id]/route.ts index 7ce3867906..b9d841f8f4 100644 --- a/src/app/api/v1/batches/[id]/route.ts +++ b/src/app/api/v1/batches/[id]/route.ts @@ -1,22 +1,13 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getBatch, deleteBatch } 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() { return handleCorsOptions(); } -function scopeCheck( - scope: { isSessionAuth: boolean; apiKeyId: string | null }, - recordApiKeyId: string | null | undefined -): boolean { - if (scope.isSessionAuth) return true; - if (recordApiKeyId === null || recordApiKeyId === undefined) return true; - return recordApiKeyId === scope.apiKeyId; -} - export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; @@ -24,7 +15,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = await params; const batch = getBatch(id); - if (!batch || !scopeCheck(scope, batch.apiKeyId)) { + // Session = operator, key = own rows only, null owner = denied + // (GHSA-2jm2-mpx8-6523): the previous local check let ANY caller read or + // delete an unowned batch by id. + if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } @@ -41,7 +35,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const { id } = await params; const batch = getBatch(id); - if (!batch || !scopeCheck(scope, batch.apiKeyId)) { + if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/batches/delete-completed/route.ts b/src/app/api/v1/batches/delete-completed/route.ts index fa60d98d61..5e095fc845 100644 --- a/src/app/api/v1/batches/delete-completed/route.ts +++ b/src/app/api/v1/batches/delete-completed/route.ts @@ -51,8 +51,8 @@ export async function DELETE(request: Request) { if (policy.rejection) return policy.rejection; // A presented API key always scopes the sweep to that key — even when the - // request also carries a dashboard session cookie — exactly like the - // list/count siblings (`apiKeyId || undefined`), so a leaked or over-shared + // request also carries a dashboard session cookie — the same rule the list + // siblings apply through `resolveListScope()`, so a leaked or over-shared // key can never widen a destructive sweep. Only a dashboard session WITHOUT a // key sweeps the whole instance; otherwise an ordinary key would delete every // tenant's completed batches and null out their file contents diff --git a/src/app/api/v1/batches/route.ts b/src/app/api/v1/batches/route.ts index f64a46d972..46e080aa26 100644 --- a/src/app/api/v1/batches/route.ts +++ b/src/app/api/v1/batches/route.ts @@ -3,7 +3,11 @@ import { createBatch, listBatches, countBatches } from "@/lib/db/batches"; import { getFile } from "@/lib/db/files"; import { v1BatchCreateSchema } from "@/shared/validation/schemas"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { + getApiKeyRequestScope, + canAccessOwnedRecord, + resolveListScope, +} from "@/app/api/v1/_helpers/apiKeyScope"; import { formatBatchResponse } from "./formatBatchResponse"; import { parseBatchListLimit } from "./parseListLimit"; @@ -32,8 +36,12 @@ export async function POST(request: Request) { } const validated = validation.data; + // The batch runs LLM requests over the input file's content, so the caller + // must be allowed to READ that file: own key, or the operator's session. A + // null-owner input file is denied to a foreign key and to an anonymous + // caller alike (GHSA-2jm2-mpx8-6523). const inputFile = getFile(validated.input_file_id); - if (!inputFile || (inputFile.apiKeyId !== null && inputFile.apiKeyId !== apiKeyId)) { + if (!inputFile || !canAccessOwnedRecord(scope, inputFile.apiKeyId)) { return NextResponse.json( { error: { message: "Input file not found", type: "invalid_request_error" } }, { status: 400, headers: CORS_HEADERS } @@ -68,7 +76,14 @@ export async function POST(request: Request) { export async function GET(request: Request) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; + + // Key → own batches only; dashboard session without a key → instance-wide; + // anonymous / unresolvable bearer → 401. `listBatches`/`countBatches` read an + // absent owner as "every tenant", so the widening must be an explicit + // decision here, never a fallback (GHSA-m3hp-hq9g-fpmv). + const listScope = resolveListScope(scope); + if (listScope.mode === "rejected") return listScope.response; + const ownerFilter = listScope.mode === "api_key" ? listScope.apiKeyId : undefined; const url = new URL(request.url); const parsedLimit = parseBatchListLimit(url.searchParams.get("limit")); @@ -81,13 +96,13 @@ export async function GET(request: Request) { const limit = parsedLimit.limit; const after = url.searchParams.get("after") || undefined; - const batches = listBatches(apiKeyId || undefined, limit + 1, after); + const batches = listBatches(ownerFilter, limit + 1, after); const hasMore = batches.length > limit; const data = hasMore ? batches.slice(0, limit) : batches; const formattedData = data.map((b) => formatBatchResponse(b)); - const totalCount = countBatches(apiKeyId || undefined); + const totalCount = countBatches(ownerFilter); return NextResponse.json( { diff --git a/src/app/api/v1/files/[id]/content/route.ts b/src/app/api/v1/files/[id]/content/route.ts index 33bf4fdab4..73255c6982 100644 --- a/src/app/api/v1/files/[id]/content/route.ts +++ b/src/app/api/v1/files/[id]/content/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getFile, getFileContent } from "@/lib/db/files"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope"; export async function OPTIONS() { return handleCorsOptions(); @@ -10,12 +10,13 @@ export async function OPTIONS() { export async function GET(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 file = getFile(id); - if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId && !scope.isSessionAuth)) { + // `getFileContent` has no ownership check of its own — this guard is the only + // thing between a caller and the raw bytes (GHSA-2jm2-mpx8-6523). + if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) { return NextResponse.json( { error: { message: "File not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/files/[id]/route.ts b/src/app/api/v1/files/[id]/route.ts index 953903cca4..91e47872e7 100644 --- a/src/app/api/v1/files/[id]/route.ts +++ b/src/app/api/v1/files/[id]/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getFile, deleteFile, formatFileResponse } from "@/lib/db/files"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope"; export async function OPTIONS() { return handleCorsOptions(); @@ -10,12 +10,14 @@ export async function OPTIONS() { export async function GET(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 file = getFile(id); - if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId && !scope.isSessionAuth)) { + // Session = operator, key = own rows only, null owner = denied + // (GHSA-2jm2-mpx8-6523). A foreign or anonymous caller gets the same 404 as + // a missing id so the id space cannot be probed. + if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) { return NextResponse.json( { error: { message: "File not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } @@ -28,21 +30,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: export async function DELETE(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 file = getFile(id); - if (!file) { - return NextResponse.json( - { error: { message: "File not found", type: "invalid_request_error" } }, - { status: 404, headers: CORS_HEADERS } - ); - } - - // Allow session-authenticated (dashboard) requests to delete any file; - // for API-key-authenticated requests, enforce scope. - if (!scope.isSessionAuth && file.apiKeyId !== null && file.apiKeyId !== apiKeyId) { + if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) { return NextResponse.json( { error: { message: "File not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/files/route.ts b/src/app/api/v1/files/route.ts index 4550755a15..63d8380782 100644 --- a/src/app/api/v1/files/route.ts +++ b/src/app/api/v1/files/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { createFile, listFiles, formatFileResponse, countFiles } from "@/lib/db/files"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, resolveListScope } from "@/app/api/v1/_helpers/apiKeyScope"; export async function OPTIONS() { return handleCorsOptions(); @@ -130,7 +130,14 @@ export async function POST(request: Request) { export async function GET(request: Request) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; + + // Key → own files only; dashboard session without a key → instance-wide; + // anonymous / unresolvable bearer → 401. `listFiles`/`countFiles` read an + // absent owner as "every tenant", so the widening must be an explicit + // decision here, never a fallback (GHSA-m3hp-hq9g-fpmv). + const listScope = resolveListScope(scope); + if (listScope.mode === "rejected") return listScope.response; + const ownerFilter = listScope.mode === "api_key" ? listScope.apiKeyId : undefined; const { searchParams } = new URL(request.url); const parsed = parseFilesListQuery(searchParams); @@ -139,7 +146,7 @@ export async function GET(request: Request) { // We fetch limit + 1 to check if there are more items const files = listFiles({ - apiKeyId: apiKeyId || undefined, + apiKeyId: ownerFilter, purpose, limit: limit + 1, after, @@ -148,7 +155,7 @@ export async function GET(request: Request) { const hasMore = files.length > limit; const data = files.slice(0, limit); - const totalCount = countFiles({ apiKeyId: apiKeyId || undefined, purpose }); + const totalCount = countFiles({ apiKeyId: ownerFilter, purpose }); return NextResponse.json( { diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index aa585b86b1..ee0d504597 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -18,6 +18,7 @@ import { after } from "next/server"; import { getModelCatalogCacheVersion } from "@/lib/db/readCache"; import { extractApiKey } from "@/sse/services/auth"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; import { catalogPageCacheKey, catalogStringResponse, parseCatalogPage } from "./catalogPagination"; import { isCodexModelCatalogClient } from "./catalogRequest"; @@ -148,9 +149,22 @@ function catalogBuildTimeoutMs(): number { const catalogLastGood = new Map(); +export class CatalogBuildTimeoutError extends Error { + constructor() { + super("catalog_build_timeout"); + this.name = "CatalogBuildTimeoutError"; + } +} + function withTimeout(promise: Promise, ms: number, label: string): Promise { return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(label)), ms); + const timer = setTimeout(() => { + if (label === "catalog_build_timeout") { + reject(new CatalogBuildTimeoutError()); + } else { + reject(new Error(label)); + } + }, ms); promise.then( (value) => { clearTimeout(timer); @@ -174,7 +188,12 @@ const catalogCache = new Map(); * It still resolves to its own original caller (that request legitimately waits * on it), just without being persisted. */ -type InFlightBuild = { generation: number; promise: Promise }; +type InFlightBuild = { + generation: number; + promise: Promise; + lastKeptAt?: number; + timeoutCount?: number; +}; const catalogInFlight = new Map(); let _catalogBuilderRuns = 0; @@ -250,8 +269,10 @@ function storePayload( }; if (buildGeneration === getModelCatalogCacheVersion()) { catalogCache.set(cacheKey, entry); + if (entry.status === 200) catalogLastGood.set(cacheKey, entry); } - if (entry.status === 200) catalogLastGood.set(cacheKey, entry); + // Cross-generation orphan: return entry to its original caller unchanged, + // persist neither cache nor lastGood. return entry; } @@ -302,7 +323,12 @@ function startBackgroundRefresh( // observes the failure. refreshPromise.catch(() => {}); - catalogInFlight.set(cacheKey, { generation, promise: refreshPromise }); + catalogInFlight.set(cacheKey, { + generation, + promise: refreshPromise, + lastKeptAt: Date.now(), + timeoutCount: 0, + }); refreshPromise .catch(() => {}) .finally(() => { @@ -329,12 +355,14 @@ async function awaitCatalogInFlight( try { payload = await withTimeout(inflight.promise, catalogBuildTimeoutMs(), "catalog_build_timeout"); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (catalogInFlight.get(cacheKey)?.promise === inflight.promise) { - catalogInFlight.delete(cacheKey); + if (!(err instanceof CatalogBuildTimeoutError)) { + if (catalogInFlight.get(cacheKey)?.promise === inflight.promise) { + catalogInFlight.delete(cacheKey); + } + throw err; } const lastGood = catalogLastGood.get(cacheKey); - if (msg === "catalog_build_timeout" && lastGood) { + if (lastGood) { return catalogStringResponse( lastGood.body, mergeCatalogHeaders(corsHeaders, lastGood.headers, diagnosticHeaders, { @@ -343,7 +371,26 @@ async function awaitCatalogInFlight( lastGood.status ); } - throw err; + const shared = catalogInFlight.get(cacheKey); + if (shared && shared.promise === inflight.promise) { + shared.timeoutCount = (shared.timeoutCount ?? 0) + 1; + shared.lastKeptAt = Date.now(); + } + const boundMs = catalogBuildTimeoutMs(); + const retryAfterSec = Math.max(1, Math.ceil((2 * boundMs) / 1000)); + const body = JSON.stringify( + buildErrorBody(503, "catalog_build_timeout", undefined, { + type: "service_unavailable", + }) + ); + return catalogStringResponse( + body, + mergeCatalogHeaders(corsHeaders, diagnosticHeaders, { + "x-omniroute-catalog": "build-timeout", + "Retry-After": String(retryAfterSec), + }), + 503 + ); } return catalogStringResponse( payload.body, @@ -406,13 +453,21 @@ export async function resolveCachedCatalogResponse( // Only join an in-flight build from the CURRENT generation. A build bound to an // older (pre-write) generation reflects stale state, so a new request starts a // fresh build instead of joining it. - if (!inflight || inflight.generation !== currentGeneration) { + const boundMs = catalogBuildTimeoutMs(); + const existing = inflight; + const joinable = + !!existing && + existing.generation === currentGeneration && + Date.now() - (existing.lastKeptAt ?? 0) <= 3 * boundMs && + (existing.timeoutCount ?? 0) < 3; + if (!joinable) { const generation = currentGeneration; const promise = runBuilder(buildPayload, request).then((payload) => storePayload(cacheKey, payload, generation) ); - inflight = { generation, promise }; + inflight = { generation, promise, lastKeptAt: Date.now(), timeoutCount: 0 }; catalogInFlight.set(cacheKey, inflight); + promise.catch(() => {}); promise.finally(() => { if (catalogInFlight.get(cacheKey)?.promise === promise) catalogInFlight.delete(cacheKey); }); @@ -483,5 +538,7 @@ export function __forceCatalogInFlightRejectionForTest(request: Request, error: catalogInFlight.set(buildCatalogCacheKey(request), { generation: getModelCatalogCacheVersion(), promise: rejected, + lastKeptAt: Date.now(), + timeoutCount: 0, }); } diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index 96c5068ff8..e3310a39ec 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "በአገልጋዩ የሚተዳደር የመሣሪያ ዑደት", "description": "ሞዴሉ በደንበኛው ሊጠቀምበት የሚችል ምላሽ እስኪመልስ ድረስ በአገልጋዩ የሚተዳደሩ ተከታታይ ያልሆኑ የመሣሪያ ጥሪዎችን ቀጥል።" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "የአጋር አገናኝ", "dismissAriaLabel": "ዝጋ" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "በ/v1/models ካታሎግ ውስጥ የአስተሳሰብ ደረጃ ተለዋጮችን (ለምሳሌ -low, -medium, -high) ማመንጨትን አሰናክል።" + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "በ/v1/models ካታሎግ ውስጥ የአስተሳሰብ ደረጃ ተለዋጮችን (ለምሳሌ -low, -medium, -high) ማመንጨትን አሰናክል።", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 183a9f54f2..0a9cec55ba 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "اعلن عن معرفات مرآة claude/<provider>/<model> على /v1/models حتى تظهر قائمة اكتشاف نماذج بوابة Claude Code نماذج غير Claude. تحذير: يؤدي إلى تكرار إدخالات الكتالوج لجميع العملاء عند تفعيله عالميًا.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "فعّل مسارات القبول الافتراضية التكيفية لكل مستأجر (tenant) لتوزيع المزودين (#9654): لم يعد انفجار حركة أحد المستأجرين يسبب خطأ 503 لمستأجر آخر. متغير البيئة OMNIROUTE_CHAT_VIRTUAL_LANES له الأولوية على هذا الإعداد في لوحة التحكم؛ تصبح التغييرات سارية بعد إعادة تشغيل الخادم.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "الصفحة الرئيسية", "dashboard": "لوحة القيادة", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "السماح لاسترداد التدفق بطلب الاستجابة مرة أخرى ودمجها بعد وصول البايتات بالفعل إلى العميل." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "تضمين حقول الأسماء المناسبة للعرض في استجابات /v1/models. عطل هذا للعملاء الذين يقبلون معرفات النماذج فقط." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "تمكين الوصول إلى الشبكة في بيئة اختبار المهارات المعزولة." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "رفض الطلبات قبل الإرسال عندما يفتقر النموذج المستهدف إلى القدرات المطلوبة (الرؤية، الأدوات، المخرجات المنظمة، نافذة السياق). يحمي الطلبات المباشرة من مزود واحد التي تتجاوز فلتر توافق الطبقة المجمعة.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "الصفحة غير موجودة", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index c9ff4293ac..0f64f36188 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models üzərində claude/<provider>/<model> güzgü id-lərini reklam edin ki, Claude Code keçid modeli kəşfiyyatında qeyri-Claude modelləri siyahıya alsın. Diqqət: qlobal olaraq aktivləşdirildikdə bütün müştərilər üçün kataloq girişlərini ikiqat artırır.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Təchizatçı göndərişi üçün hər bir icarəçi (tenant) üzrə adaptiv virtual qəbul zolaqlarını aktivləşdirin (#9654): bir icarəçinin ani yükü artıq digərinə 503 qaytarmır. OMNIROUTE_CHAT_VIRTUAL_LANES mühit dəyişəni bu idarəetmə paneli ayarından üstündür; dəyişikliklər server yenidən işə salındıqda qüvvəyə minir.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Baytlar artıq müştəriyə çatdıqdan sonra axının bərpasına cavabı yenidən sorğulamağa və onu birləşdirməyə icazə verin." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models cavablarına göstərilməsi asan olan ad sahələrini daxil edin. Bunu yalnız model ID-lərini qəbul edən müştərilər üçün sıradan çıxarın." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Bacarıqlar sandbox-unda şəbəkəyə girişi aktivləşdirin." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tələb olunan imkanlar (görmə, alətlər, strukturlaşdırılmış çıxış, kontekst pəncərəsi) olmayan hədəf modelində göndərilmədən əvvəl tələbləri rədd edin. Kombinasiya qatının uyğunluq filtrini keçən birbaşa tək təminatçı tələblərini qoruyur.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Səhifə tapılmadı", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 53a43ecc88..894e89ceb2 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Рекламирайте claude/<provider>/<model> mirror идентификатори на /v1/models, така че списъкът с модели на Claude Code gateway да включва неклаудови модели. Внимание: удвоява записите в каталога за всички клиенти, когато е активирано глобално.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Активирайте адаптивни виртуални ленти за допускане за всеки наемател (tenant) при изпращане към доставчици (#9654): скокът в натоварването на един наемател вече не връща 503 на друг. Променливата на средата OMNIROUTE_CHAT_VIRTUAL_LANES има предимство пред тази настройка в таблото; промените влизат в сила след рестартиране на сървъра.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Начало", "dashboard": "Табло", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Разрешаване на възстановяването на потока да поиска отговора отново и да го съедини, след като байтовете вече са достигнали до клиента." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Включване на лесни за четене полета за имена в отговорите на /v1/models. Деактивирайте това за клиенти, които приемат само идентификатори на модели." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Активиране на мрежов достъп в пясъчника за умения." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Отхвърлете заявките преди изпращане, когато целевият модел няма необходимите възможности (визия, инструменти, структурирани изходи, контекстен прозорец). Защитава директните заявки от един доставчик, които заобикалят филтъра за съвместимост на комбинирания слой.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Страницата не е намерена", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index ffb54ef8d6..c2812aedd3 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models এ claude/<provider>/<model> মিরর আইডি বিজ্ঞাপন দিন যাতে Claude Code গেটওয়ে মডেল আবিষ্কার non-Claude মডেল তালিকাভুক্ত করে। সতর্কতা: এটি গ্লোবালি সক্ষম হলে সমস্ত ক্লায়েন্টের জন্য ক্যাটালগ এন্ট্রি দ্বিগুণ করে।", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "প্রোভাইডার ডিসপ্যাচের জন্য প্রতি-টেন্যান্ট অ্যাডাপ্টিভ ভার্চুয়াল অ্যাডমিশন লেন সক্ষম করুন (#9654): এক টেন্যান্টের বিস্ফোরণ আর অন্য টেন্যান্টে 503 ফেরায় না। OMNIROUTE_CHAT_VIRTUAL_LANES এনভায়রনমেন্ট ভেরিয়েবল এই ড্যাশবোর্ড সেটিংয়ের উপরে প্রাধান্য পায়; পরিবর্তনগুলি সার্ভার পুনরায় চালু হলে কার্যকর হয়।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ক্লায়েন্টের কাছে ইতিমধ্যে বাইট পৌঁছানোর পরে স্ট্রিম রিকভারিকে প্রতিক্রিয়ার জন্য আবার অনুরোধ করার এবং এটি যুক্ত করার অনুমতি দিন।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models প্রতিক্রিয়াগুলিতে প্রদর্শন-বান্ধব নামের ক্ষেত্রগুলি অন্তর্ভুক্ত করুন। শুধুমাত্র মডেল ID গ্রহণ করে এমন ক্লায়েন্টদের জন্য এটি নিষ্ক্রিয় করুন।" }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "স্কিল স্যান্ডবক্সে নেটওয়ার্ক অ্যাক্সেস সক্ষম করুন।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "লক্ষ্য মডেলের প্রয়োজনীয় সক্ষমতা (দৃষ্টি, সরঞ্জাম, কাঠামোবদ্ধ আউটপুট, প্রসঙ্গ উইন্ডো) অনুপস্থিত থাকলে প্রেরণের আগে অনুরোধগুলি প্রত্যাখ্যান করুন। এটি কম্বো-লেয়ার সামঞ্জস্য ফিল্টারকে বাইপাস করা সরাসরি একক-প্রদানকারী অনুরোধগুলি রক্ষা করে।", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "পৃষ্ঠা পাওয়া যায়নি", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index df7ff0803e..41168f4317 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Inzerujte claude/<provider>/<model> zrcadlové ID na /v1/models, aby seznam objevování modelů brány Claude Code zahrnoval modely, které nejsou Claude. Upozornění: při globálním povolení zdvojuje katalogové položky pro všechny klienty.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Povolte adaptivní virtuální vstupní pruhy pro každého tenanta při odesílání poskytovatelům (#9654): špička jednoho tenanta už nezpůsobí 503 u jiného. Proměnná prostředí OMNIROUTE_CHAT_VIRTUAL_LANES má přednost před tímto nastavením na řídicím panelu; změny se projeví po restartu serveru.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Domov", "dashboard": "Nástěnka", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Povolit obnovení streamu pro opětovné vyžádání odpovědi a její spojení poté, co bajty již dorazily ke klientovi." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Zahrnout uživatelsky přívětivá pole názvů v odpovědích /v1/models. Zakažte to pro klienty, kteří přijímají pouze ID modelů." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Povolit přístup k síti v sandboxu dovedností." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Odmítnout požadavky před odesláním, když cílový model postrádá požadované schopnosti (vidění, nástroje, strukturovaný výstup, kontextové okno). Chrání přímé požadavky od jednotlivých poskytovatelů, které obcházejí filtr kompatibility kombinované vrstvy.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Stránka nebyla nalezena", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index e817957613..5ed7b52a32 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamer claude/<provider>/<model> spejl-id'er på /v1/models, så Claude Code gateway modelopdagelse viser ikke-Claude modeller. Advarsel: fordobler katalogposter for alle klienter, når det er aktiveret globalt.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktivér adaptive virtuelle adgangsbaner pr. tenant til providerudlevering (#9654): en tenants burst giver ikke længere en anden 503. Miljøvariablen OMNIROUTE_CHAT_VIRTUAL_LANES har forrang over denne dashboard-indstilling; ændringer træder i kraft ved genstart af serveren.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Hjem", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Tillad stream-gendannelse at anmode om svaret igen og sammenføje det, efter at bytes allerede har nået klienten." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkluder visningsvenlige navnefelter i /v1/models-svar. Deaktivér dette for klienter, der kun accepterer model-id'er." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktivér netværksadgang i skills-sandkassen." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Afvis anmodninger før afsendelse, når målmodellen mangler de nødvendige funktioner (vision, værktøjer, struktureret output, kontekstvindue). Beskytter direkte anmodninger fra en enkelt udbyder, der omgår kombinationslagets kompatibilitetsfilter.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Siden blev ikke fundet", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b50c224873..ba6a0ba9d8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Bewerben Sie claude/<provider>/<model> Spiegel-IDs auf /v1/models, damit die Claude Code-Gateway-Modellentdeckung Nicht-Claude-Modelle auflistet. Warnung: Verdoppelt Katalogeinträge für alle Clients, wenn global aktiviert.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktivieren Sie adaptive virtuelle Zulassungsspuren pro Tenant für die Provider-Zustellung (#9654): Ein Burst eines Tenants führt nicht mehr zu 503 bei einem anderen. Die Umgebungsvariable OMNIROUTE_CHAT_VIRTUAL_LANES hat Vorrang vor dieser Dashboard-Einstellung; Änderungen werden erst nach einem Serverneustart wirksam.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Zuhause", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Stream-Wiederherstellung erlauben, um die Antwort erneut anzufordern und zusammenzufügen, nachdem bereits Bytes den Client erreicht haben." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Benutzerfreundliche Namensfelder in /v1/models-Antworten einschließen. Deaktivieren Sie dies für Clients, die nur Modell-IDs akzeptieren." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Netzwerkzugriff in der Skills-Sandbox aktivieren." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Lehnen Sie Anfragen ab, bevor sie versendet werden, wenn das Zielmodell über die erforderlichen Funktionen (Vision, Werkzeuge, strukturierte Ausgabe, Kontextfenster) nicht verfügt. Schützt direkte Einzelanbieteranfragen, die den Kombo-Schicht-Kompatibilitätsfilter umgehen.", "featureFlagDisableContextWindowChecksDescription": "Lokale Kontextfenster- und Maximaleingabetoken-Prüfung von OmniRoute für direkte Einzelmodell-Anfragen überspringen. Upstream-Anbieter erzwingen weiterhin ihre tatsächlichen Grenzen. Prompt-Komprimierung und Ausgabetoken-Grenzen bleiben aktiv.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Seite nicht gefunden", diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 5a6f50b767..60d7e0e4eb 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Διαφήμιση αναγνωριστικών καθρέφτη claude/<provider>/<model> στο /v1/models ώστε η ανακάλυψη μοντέλων πύλης Claude Code να εμφανίζει μη-Claude μοντέλα. Προειδοποίηση: διπλασιάζει τις εγγραφές καταλόγου για όλους τους πελάτες όταν ενεργοποιείται καθολικά.", "featureFlagNoThinkingAliasEnabledDescription": "Κύριος διακόπτης για τα ψευδώνυμα πύλης no-think/<provider>/<model>. Ενεργό (προεπιλογή): το /v1/models διαφημίζει μια παραλλαγή χωρίς σκέψη για κάθε κατάλληλο Claude μοντέλο ικανό για σκέψη, και ένα αναγνωριστικό no-think/ που αποστέλλεται σε αίτημα επιλύεται στο πραγματικό μοντέλο με καταστολή του συλλογισμού. Ανενεργό: δεν διαφημίζονται παραλλαγές και ένα αναγνωριστικό no-think/ αντιμετωπίζεται όπως οποιοδήποτε άλλο άγνωστο αναγνωριστικό μοντέλου. Η επιλογή ενεργοποίησης/απενεργοποίησης ανά μοντέλο ModelSpec.noThinkingAlias εξακολουθεί να ισχύει ενώ αυτό είναι ενεργό.", "featureFlagChatVirtualLanesEnabledDescription": "Ενεργοποίηση προσαρμοστικών εικονικών λωρίδων αποδοχής ανά ενοικιαστή για αποστολή παρόχου (#9654): η έκρηξη ενός ενοικιαστή δεν προκαλεί πλέον 503 σε άλλον. Η μεταβλητή περιβάλλοντος OMNIROUTE_CHAT_VIRTUAL_LANES υπερισχύει αυτής της παράκαμψης του πίνακα ελέγχου· οι αλλαγές τίθενται σε ισχύ κατά την επανεκκίνηση του διακομιστή.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Αρχική", "dashboard": "Πίνακας Ελέγχου", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Εξουσιοδότηση αποκατάστασης ροής να ζητά εκ νέου την απόκριση και να την συνενώνει αφού bytes έχουν ήδη φτάσει στον πελάτη." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Συμπερίληψη φιλικών προς εμφάνιση πεδίων ονόματος στις αποκρίσεις /v1/models. Απενεργοποιήστε το για πελάτες που δέχονται μόνο αναγνωριστικά μοντέλων." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Βρόχος εργαλείων ελεγχόμενος από τον διακομιστή", "description": "Συνέχιση των μη συνεχιζόμενων κλήσεων εργαλείων που ελέγχονται από τον διακομιστή, έως ότου το μοντέλο επιστρέψει μια απόκριση που μπορεί να χρησιμοποιήσει ο πελάτης." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Απόρριψη αιτημάτων πριν από την αποστολή όταν το στοχευόμενο μοντέλο δεν διαθέτει τις απαιτούμενες δυνατότητες (όραση, εργαλεία, δομημένη έξοδος, παράθυρο περιβάλλοντος). Προστατεύει άμεσα αιτήματα μεμονωμένου παρόχου που παρακάμπτουν το φίλτρο συμβατότητας του επιπέδου combo.", "featureFlagDisableContextWindowChecksDescription": "Παράλειψη του τοπικού ελέγχου παραθύρου περιβάλλοντος και μέγιστου εισόδου διακριτικών του OmniRoute για άμεσα αιτήματα μεμονωμένου μοντέλου. Οι upstream πάροχοι εξακολουθούν να επιβάλλουν τα πραγματικά τους όρια. Η συμπίεση προτροπής και τα ανώτατα όρια διακριτικών εξόδου παραμένουν ενεργά.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Η σελίδα δεν βρέθηκε", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 508e3f4ee2..952b9d7892 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Advertise claude/<provider>/<model> mirror ids on /v1/models so Claude Code gateway model discovery lists non-Claude models. Warning: doubles catalog entries for all clients when enabled globally.", "featureFlagNoThinkingAliasEnabledDescription": "Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Enable per-tenant adaptive virtual admission lanes for provider dispatch (#9654): one tenant's burst no longer 503s another. The OMNIROUTE_CHAT_VIRTUAL_LANES env var wins over this dashboard override; changes take effect at server restart.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server-Owned Tool Loop", "description": "Continue non-streaming server-owned tool calls until the model returns a client-usable response." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "Retry-After Provenance", + "description": "On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "Protected-Priority Infra Stops as 502", + "description": "Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "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.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Page not found", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1d2f539aeb..e2ecb77e0d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Anunciar los ids de espejo claude/<provider>/<model> en /v1/models para que la lista de descubrimiento de modelos del gateway de Claude Code incluya modelos que no son de Claude. Advertencia: duplica las entradas del catálogo para todos los clientes cuando se habilita globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Activa carriles de admisión virtuales adaptativos por tenant para el envío de proveedores (#9654): el pico de un tenant ya no devuelve 503 a otro. La variable de entorno OMNIROUTE_CHAT_VIRTUAL_LANES tiene prioridad sobre esta opción del panel; los cambios surten efecto al reiniciar el servidor.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Inicio", "dashboard": "Panel de control", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Enable network access in the skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rechazar solicitudes antes del despacho cuando el modelo objetivo carece de capacidades requeridas (visión, herramientas, salida estructurada, ventana de contexto). Protege las solicitudes directas de un solo proveedor que eluden el filtro de compatibilidad de la capa combinada.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Página no encontrada", diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index d3d818f641..477a2c1872 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Avaldage claude/<provider>/<model> peegel-ID-d lõpp-punktis /v1/models, et Claude Code'i lüüsi mudeliotsing loetleks ka mitte-Claude'i mudelid. Hoiatus: globaalsel lubamisel kahekordistab see kõigi klientide kataloogikirjete arvu.", "featureFlagNoThinkingAliasEnabledDescription": "Lüüsi no-think/<provider>/<model> aliaste pealüliti. Sees (vaikimisi): /v1/models avaldab iga sobiliku mõtlemisvõimelise Claude'i mudeli jaoks mõtlemiseta variandi ning päringus saadetud no-think/ ID lahendatakse tagasi tegelikuks mudeliks, mille arutluskäik on maha surutud. Väljas: variante ei avaldata ja no-think/ ID-d käsitletakse nagu mis tahes muud tundmatut mudeli-ID-d. Kui see on sisse lülitatud, kehtib endiselt mudelipõhine ModelSpec.noThinkingAlias lubamisest või keelamisest loobumise säte.", "featureFlagChatVirtualLanesEnabledDescription": "Lubage pakkujale edastamiseks rentnikupõhised kohanduvad virtuaalsed vastuvõturajad (#9654): ühe rentniku koormushoog ei põhjusta enam teisele tõrget 503. Keskkonnamuutuja OMNIROUTE_CHAT_VIRTUAL_LANES alistab selle juhtpaneeli sätte; muudatused jõustuvad serveri taaskäivitamisel.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Avaleht", "dashboard": "Juhtpaneel", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Luba voo taastamisel vastus uuesti pärida ja jätkata selle liitmist pärast seda, kui baidid on juba kliendini jõudnud." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Kaasa /v1/models vastustesse kuvamiseks sobivad nimeväljad. Keela see klientide puhul, mis aktsepteerivad ainult mudeli-ID-sid." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Serveri hallatav tööriistatsükkel", "description": "Jätka serveri hallatavate voogedastuseta tööriistakutsete tegemist, kuni mudel tagastab kliendi jaoks kasutatava vastuse." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Lükka päringud enne edastamist tagasi, kui sihtmudelil puuduvad nõutavad võimalused (nägemine, tööriistad, struktureeritud väljund, kontekstiaken). See kaitseb ühe teenusepakkuja otsepäringuid, mis mööduvad kombokihi ühilduvusfiltrist.", "featureFlagDisableContextWindowChecksDescription": "Jäta ühe mudeli otsepäringute puhul OmniRoute'i kohalik kontekstiakna ja sisendtokenite maksimumarvu kontroll vahele. Ülesvoolu teenusepakkujad jõustavad endiselt oma tegelikud piirangud. Viiba tihendamine ja väljundtokenite piirangud jäävad aktiivseks.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Lehte ei leitud", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 1f00f515c3..3936ffb372 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "آگهی شناسه‌های آینه claude/<provider>/<model> را در /v1/models به‌گونه‌ای تنظیم کنید که لیست کشف مدل‌های دروازه کد Claude شامل مدل‌های غیر Claude باشد. هشدار: در صورت فعال‌سازی جهانی، ورودی‌های کاتالوگ را برای تمام مشتریان دو برابر می‌کند.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "خط‌های پذیرش مجازی تطبیقی به‌ازای هر مستاجر (tenant) را برای ارسال به ارائه‌دهندگان فعال کنید (#9654): افزایش ناگهانی بار یک مستاجر دیگر خطای 503 را برای مستاجر دیگر ایجاد نمی‌کند. متغیر محیطی OMNIROUTE_CHAT_VIRTUAL_LANES بر این تنظیم داشبورد اولویت دارد؛ تغییرات پس از راه‌اندازی مجدد سرور اعمال می‌شوند.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "اجازه دادن به بازیابی جریان برای درخواست مجدد پاسخ و پیوند زدن آن پس از اینکه بایت‌ها قبلاً به کلاینت رسیده‌اند." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "گنجاندن فیلدهای نام مناسب برای نمایش در پاسخ‌های /v1/models. این را برای کلاینت‌هایی که فقط شناسه مدل را می‌پذیرند غیرفعال کنید." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "فعال‌سازی دسترسی به شبکه در محیط ایزوله مهارت‌ها." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "درخواست‌ها را قبل از ارسال رد کنید زمانی که مدل هدف قابلیت‌های مورد نیاز (بینایی، ابزارها، خروجی ساختاریافته، پنجره زمینه) را ندارد. از درخواست‌های مستقیم تک‌تأمین‌کننده که فیلتر سازگاری لایه ترکیبی را دور می‌زنند، محافظت می‌کند.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "صفحه پیدا نشد", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 4a1a88098f..747382aa86 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Mainosta claude/<provider>/<model> peilid tunnuksia /v1/models, jotta Claude Code -portin mallin löytölistalla näkyvät ei-Claude-mallit. Varoitus: kaksinkertaistaa luettelo-merkinnät kaikille asiakkaille, kun se on otettu käyttöön globaalisti.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Ota käyttöön mukautuvat virtuaaliset sisäänottokaistat vuokraajaa (tenant) kohti palveluntarjoajien välitystä varten (#9654): yhden vuokraajan kuormapiikki ei enää aiheuta 503-virhettä toiselle. Ympäristömuuttuja OMNIROUTE_CHAT_VIRTUAL_LANES ohittaa tämän hallintapaneelin asetuksen; muutokset tulevat voimaan palvelimen uudelleenkäynnistyksessä.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Kotiin", "dashboard": "Kojelauta", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Salli virran palautuksen pyytää vastausta uudelleen ja liittää se sen jälkeen, kun tavuja on jo saapunut asiakkaalle." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sisällytä näyttöystävälliset nimikentät /v1/models-vastauksiin. Poista tämä käytöstä asiakkaille, jotka hyväksyvät vain mallitunnuksia." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Ota käyttöön verkkoyhteys taitojen hiekkalaatikossa." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Hylkää pyynnöt ennen lähettämistä, kun kohdemallilta puuttuu vaadittuja ominaisuuksia (näkö, työkalut, jäsennelty ulostulo, kontekstikkelu). Suojaa suorat yhden tarjoajan pyynnöt, jotka ohittavat yhdistelmäkerroksen yhteensopivuussuodattimen.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Sivua ei löytynyt", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1de3c1a998..810908b273 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Afficher les identifiants miroir claude/<provider>/<model> dans /v1/models afin que la découverte de modèles de la passerelle Claude Code répertorie les modèles non-Claude. Attention : cette option double les entrées du catalogue pour tous les clients lorsqu'elle est activée globalement.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Activez des voies d'admission virtuelles adaptatives par tenant pour la répartition des fournisseurs (#9654) : le pic d'un tenant ne renvoie plus 503 à un autre. La variable d'environnement OMNIROUTE_CHAT_VIRTUAL_LANES prime sur ce réglage du tableau de bord ; les modifications prennent effet au redémarrage du serveur.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Accueil", "dashboard": "Tableau de bord", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Autoriser la récupération de flux à demander à nouveau la réponse et à la raccorder après que des octets ont déjà atteint le client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inclure des champs de nom conviviaux pour l'affichage dans les réponses /v1/models. Désactivez cette option pour les clients qui n'acceptent que les ID de modèle." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Activer l'accès réseau dans le bac à sable des compétences." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rejeter les demandes avant l'expédition lorsque le modèle cible manque des capacités requises (vision, outils, sortie structurée, fenêtre de contexte). Protège les demandes directes à un seul fournisseur qui contournent le filtre de compatibilité de la couche combo.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Page introuvable", diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 111f3a290f..7abdd92b38 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Fógraigh aitheantais scátháin claude/<soláthraí>/<samhail> ar /v1/models ionas go bhfógróidh fionnachtain samhla geata Claude Code samhlacha neamh-Claude. Rabhadh: déanann sé iontrálacha catalóige a dhúbailt do gach cliant nuair a chumasaítear go domhanda é.", "featureFlagNoThinkingAliasEnabledDescription": "Príomh-lasc do na haliasanna geataí no-think/<soláthraí>/<samhail>. Ar (réamhshocrú): fógraíonn /v1/models leagan gan smaoineamh do gach samhail Claude atá in ann smaoineamh, agus réitíonn aitheantas no-think/ a sheoltar ar iarratas ar ais go dtí an tsamhail fíor le réasúnaíocht faoi chois. As: ní fhógraítear aon leaganacha agus caitear le haitheantas no-think/ mar aon aitheantas samhla anaithnid eile. Tá an rogha per-model ModelSpec.noThinkingAlias fós i bhfeidhm agus é seo ar siúl.", "featureFlagChatVirtualLanesEnabledDescription": "Cumasaigh lánaí iontrála oiriúnaitheacha fíorúla in aghaidh an tionónta le haghaidh seolta soláthraí (#9654): ní chruthaíonn pléascadh tionónta amháin 503 do thionónta eile a thuilleadh. Tá an athróg timpeallachta OMNIROUTE_CHAT_VIRTUAL_LANES níos cumhachtaí ná an sárú deais seo; tagann athruithe i bhfeidhm ag atosú freastalaí.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Baile", "dashboard": "Deais", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ceadaigh d'aisghabháil srutha an freagra a iarraidh arís agus é a fhuáil le chéile tar éis do bhearta a bheith sroichte ag an gcliant cheana féin." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Cuir réimsí ainmneacha atá cairdiúil don taispeáint san áireamh i bhfreagraí /v1/models. Díchumasaigh é seo do chliaint a ghlacann le haitheantóirí múnla amháin." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Lúb Uirlisí faoi Úinéireacht an Fhreastalaí", "description": "Lean ar aghaidh le glaonna uirlise neamhshruthaithe atá faoi úinéireacht an fhreastalaí go dtí go gcuirfidh an tsamhail freagra ar fáil is féidir leis an gcliant a úsáid." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Diúltaigh iarratais roimh seoladh nuair nach bhfuil cumais riachtanacha ag an sprioc-mhúnla (radharc, uirlisí, aschur struchtúrtha, fuinneog comhthéacs). Cosnaíonn sé iarrataí aonair díreach-aonair-bhunaithe a sheachann an scagaire comhoiriúlachta sraithe chomhcheangail.", "featureFlagDisableContextWindowChecksDescription": "Léim thar seiceáil fuinneog comhthéacs agus ionchur comhartha uasta OmniRoute d'iarrataí múnla-aonair dhíreacha. Fórsíonn na soláthraithe suasshrutha a dteorainn fíor-fholláin fós. Fanann comhbhrú leideanna agus teorainn aschur comhartha gníomhach.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Leathanach gan aimsiú", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 60c163447d..1abae7effd 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models પર claude/<provider>/<model> મિરર આઈડીઓનું જાહેરાત કરો જેથી Claude Code ગેટવે મોડલ શોધી કાઢે છે non-Claude મોડલ. ચેતવણી: જ્યારે વૈશ્વિક રીતે સક્રિય કરવામાં આવે ત્યારે તમામ ક્લાયન્ટ માટે કૅટલોગ એન્ટ્રીઓ ડબલ કરે છે.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "પ્રોવાઇડર ડિસ્પેચ માટે પ્રતિ-ટેનન્ટ અનુકૂલનશીલ વર્ચ્યુઅલ એડમિશન લેન સક્ષમ કરો (#9654): એક ટેનન્ટનો બર્સ્ટ હવે બીજા ટેનન્ટને 503 આપતો નથી. OMNIROUTE_CHAT_VIRTUAL_LANES એન્વાયર્નમેન્ટ વેરિયેબલ આ ડેશબોર્ડ સેટિંગ કરતાં વધુ પ્રાધાન્ય ધરાવે છે; ફેરફારો સર્વર પુનઃપ્રારંભ પર અસરકારક થાય છે.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "બાઇટ્સ પહેલેથી જ ક્લાયન્ટ સુધી પહોંચી ગયા પછી પ્રતિસાદની ફરીથી વિનંતી કરવા અને તેને જોડવા માટે સ્ટ્રીમ પુનઃપ્રાપ્તિને મંજૂરી આપો." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models પ્રતિસાદોમાં પ્રદર્શન-અનુકૂળ નામ ફીલ્ડ્સ શામેલ કરો. ફક્ત મોડલ IDs સ્વીકારતા ક્લાયન્ટ્સ માટે આને નિષ્ક્રિય કરો." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "સ્કિલ્સ સેન્ડબોક્સમાં નેટવર્ક એક્સેસ સક્ષમ કરો." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "જ્યારે લક્ષ્ય મોડેલમાં જરૂરી ક્ષમતાઓ (દૃષ્ટિ, સાધનો, રચિત આઉટપુટ, સંદર્ભ વિન્ડો) નથી ત્યારે વિતરણ પહેલાં વિનંતીઓને નકારી નાખો. કોમ્બો-લેયર સુસંગતતા ફિલ્ટરને બાયપાસ કરતી સીધી એકલ-પ્રદાતા વિનંતિઓને સુરક્ષિત કરે છે.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "પૃષ્ઠ મળ્યું નથી", diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 7f0aa59b2d..067614c87a 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Madaukin Kayan Aiki Mallakar Sabar", "description": "Ci gaba da kiran kayan aikin sabar marasa gudana har sai samfurin ya dawo da amsar da abokin ciniki zai iya amfani da ita." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "Hanyar haɗin abokin hulɗa", "dismissAriaLabel": "Yi watsi" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Kashe samar da nau'ikan matakan tunani (misali -low, -medium, -high) a cikin kundin /v1/models." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Kashe samar da nau'ikan matakan tunani (misali -low, -medium, -high) a cikin kundin /v1/models.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 9db4315147..3c0c6b974c 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "פרסם את מזהי המראה של claude/<provider>/<model> ב-/v1/models כך שרשימות גילוי המודלים של Claude Code יכללו מודלים שאינם של Claude. אזהרה: מכפיל את רשומות הקטלוג עבור כל הלקוחות כאשר זה מופעל באופן גלובלי.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "הפעל נתיבי קבלה וירטואליים אדפטיביים לכל דייר (tenant) עבור שליחת ספקים (#9654): פרץ עומס של דייר אחד כבר לא מחזיר 503 לדייר אחר. משתנה הסביבה OMNIROUTE_CHAT_VIRTUAL_LANES גובר על הגדרה זו בלוח הבקרה; השינויים נכנסים לתוקף לאחר הפעלת השרת מחדש.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "בית", "dashboard": "לוח מחוונים", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "התרת שחזור זרם כדי לבקש את התגובה מחדש ולחבר אותה לאחר שביתים כבר הגיעו ללקוח." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "הכללת שדות שם ידידותיים לתצוגה בתגובות של /v1/models. השבת זאת עבור לקוחות המקבלים מזהי מודל בלבד." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "הפעלת גישה לרשת בארגז החול של המיומנויות." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "דחה בקשות לפני שליחה כאשר המודל המטרה חסר יכולות נדרשות (חזון, כלים, פלט מובנה, חלון הקשר). מגן על בקשות ישירות מספק אחד שעוקפות את מסנן ההתאמה של שכבת הקומבו.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "העמוד לא נמצא", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index e9670d7747..71366e295f 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models पर claude/<provider>/<model> मिरर आईडी का विज्ञापन करें ताकि Claude Code गेटवे मॉडल खोज सूची में गैर-Claude मॉडल शामिल हो सकें। चेतावनी: जब वैश्विक रूप से सक्षम किया जाता है तो सभी ग्राहकों के लिए कैटलॉग प्रविष्टियों को डबल करता है।", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "प्रदाता डिस्पैच के लिए प्रति-टेनेंट अनुकूली वर्चुअल एडमिशन लेन सक्षम करें (#9654): एक टेनेंट का बर्स्ट अब दूसरे टेनेंट को 503 नहीं देता। OMNIROUTE_CHAT_VIRTUAL_LANES पर्यावरण चर इस डैशबोर्ड सेटिंग पर प्राथमिकता रखता है; परिवर्तन सर्वर पुनः आरंभ पर प्रभावी होते हैं।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "घर", "dashboard": "डैशबोर्ड", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "बाइट्स पहले से ही क्लाइंट तक पहुँचने के बाद प्रतिक्रिया का फिर से अनुरोध करने और उसे जोड़ने के लिए स्ट्रीम रिकवरी की अनुमति दें।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिक्रियाओं में प्रदर्शन-अनुकूल नाम फ़ील्ड शामिल करें। उन क्लाइंट्स के लिए इसे अक्षम करें जो केवल मॉडल ID स्वीकार करते हैं।" }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "स्किल्स सैंडबॉक्स में नेटवर्क एक्सेस सक्षम करें।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "डिस्पैच से पहले अनुरोधों को अस्वीकार करें जब लक्षित मॉडल आवश्यक क्षमताओं (दृष्टि, उपकरण, संरचित आउटपुट, संदर्भ विंडो) से रहित हो। यह सीधे एकल-प्रदाता अनुरोधों की रक्षा करता है जो कॉम्बो-लेयर संगतता फ़िल्टर को बायपास करते हैं।", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "पृष्ठ नहीं मिला", diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index ead5ea6cab..9fb0008554 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Oglašavaj claude/<provider>/<model> zrcalne identifikatore na /v1/models kako bi otkrivanje modela Claude Code gatewaya prikazivalo modele koji nisu Claude. Upozorenje: udvostručuje unose kataloga za sve klijente kada je globalno omogućeno.", "featureFlagNoThinkingAliasEnabledDescription": "Glavni prekidač za no-think/<provider>/<model> pseudonime gatewaya. Uključeno (zadano): /v1/models oglašava varijantu bez razmišljanja za svaki prihvatljivi Claude model sposoban za razmišljanje, a no-think/ identifikator poslan u zahtjevu razrješava se natrag na pravi model s potisnutim zaključivanjem. Isključeno: nijedna varijanta se ne oglašava i no-think/ identifikator tretira se kao bilo koji drugi nepoznati identifikator modela. Opt-in/opt-out ModelSpec.noThinkingAlias po modelu i dalje se primjenjuje dok je ovo uključeno.", "featureFlagChatVirtualLanesEnabledDescription": "Omogući adaptivne virtualne prijamne trake po korisniku za raspodjelu pružatelja (#9654): opterećenje jednog korisnika više neće uzrokovati 503 grešku drugome. Varijabla okoline OMNIROUTE_CHAT_VIRTUAL_LANES ima prednost nad ovim nadjačavanjem nadzorne ploče; promjene stupaju na snagu pri ponovnom pokretanju poslužitelja.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Početna", "dashboard": "Nadzorna ploča", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Dopusti oporavku toka da ponovo zatraži odgovor i spoji ga nakon što su bajtovi već stigli do klijenta." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Uključi polja s imenima prilagođenim za prikaz u odgovorima /v1/models. Onemogući ovo za klijente koji prihvaćaju samo ID-ove modela." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Petlja alata pod nadzorom poslužitelja", "description": "Nastavi s nestrujnim pozivima alata pod nadzorom poslužitelja sve dok model ne vrati odgovor koji klijent može upotrijebiti." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Odbij zahtjeve prije otpreme kada ciljnom modelu nedostaju potrebne mogućnosti (vizija, alati, strukturirani izlaz, kontekstni prozor). Štiti izravne zahtjeve prema jednom pružatelju koji zaobilaze filtar kompatibilnosti combo-sloja.", "featureFlagDisableContextWindowChecksDescription": "Preskoči OmniRoute-ovu lokalnu provjeru kontekstnog prozora i maksimalnog ulaznog tokena za izravne zahtjeve prema jednom modelu. Uzlazni pružatelji i dalje primjenjuju stvarna ograničenja. Kompresija upita i ograničenja izlaznih tokena ostaju aktivni.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Stranica nije pronađena", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 39dce97236..3a5a561340 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Hirdesse a claude/<provider>/<model> tükör azonosítókat a /v1/models-on, hogy a Claude Code átjáró modell felfedezése nem Claude modelleket is listázzon. Figyelmeztetés: globális engedélyezés esetén megduplázza a katalógus bejegyzéseket minden kliens számára.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Tegye lehetővé a bérlőnkénti adaptív virtuális beléptetősávokat a szolgáltatók felé történő továbbításhoz (#9654): az egyik bérlő kiugró terhelése már nem okoz 503-as hibát egy másiknál. Az OMNIROUTE_CHAT_VIRTUAL_LANES környezeti változó felülírja ezt a vezérlőpult-beállítást; a változtatások a szerver újraindításakor lépnek életbe.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Otthon", "dashboard": "Irányítópult", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Az adatfolyam-helyreállítás engedélyezése a válasz újbóli lekérésére és összefűzésére, miután a bájtok már elérték az ügyfelet." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Megjelenítésbarát névmezők szerepeltetése a /v1/models válaszokban. Tiltsa le ezt azon ügyfelek esetében, amelyek csak modell-azonosítókat fogadnak el." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Hálózati hozzáférés engedélyezése a készségek homokozójában." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Elutasítja a kéréseket a kiszállítás előtt, amikor a célmodell hiányzik a szükséges képességekből (látás, eszközök, strukturált kimenet, kontextusablak). Védi a közvetlen, egy szolgáltatótól érkező kéréseket, amelyek megkerülik a kombinált réteg kompatibilitási szűrőt.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Az oldal nem található", diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 001f14aaec..899f71cab6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Սերվերի կողմից կառավարվող գործիքային ցիկլ", "description": "Շարունակել սերվերի կողմից կառավարվող ոչ հոսքային գործիքների կանչերը, մինչև մոդելը վերադարձնի հաճախորդի համար օգտագործելի պատասխան։" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "Գործընկերային հղում", "dismissAriaLabel": "Փակել" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Անջատել մտածողության մակարդակի տարբերակների (օր.՝ -low, -medium, -high) ստեղծումը /v1/models կատալոգում։" + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Անջատել մտածողության մակարդակի տարբերակների (օր.՝ -low, -medium, -high) ստեղծումը /v1/models կատալոգում։", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5f9a9f7459..68c93e871c 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Iklankan claude/<provider>/<model> mirror ids di /v1/models sehingga daftar penemuan model gateway Claude Code mencantumkan model non-Claude. Peringatan: menggandakan entri katalog untuk semua klien saat diaktifkan secara global.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktifkan jalur penerimaan virtual adaptif per-tenant untuk pengiriman penyedia (#9654): lonjakan satu tenant tidak lagi mengembalikan 503 ke tenant lain. Variabel lingkungan OMNIROUTE_CHAT_VIRTUAL_LANES menang atas pengaturan dasbor ini; perubahan berlaku setelah server dimulai ulang.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Rumah", "dashboard": "Dasbor", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Izinkan pemulihan streaming untuk meminta respons kembali dan menggabungkannya setelah byte telah mencapai klien." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sertakan bidang nama yang mudah dibaca dalam respons /v1/models. Nonaktifkan ini untuk klien yang hanya menerima ID model." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktifkan akses jaringan di sandbox keterampilan." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tolak permintaan sebelum pengiriman ketika model target tidak memiliki kemampuan yang diperlukan (visi, alat, output terstruktur, jendela konteks). Melindungi permintaan penyedia tunggal langsung yang melewati filter kompatibilitas lapisan kombinasi.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Halaman tidak ditemukan", diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 1190e530c1..68a874b3cf 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Okirikiri Ngwaọrụ nke Sava Na-achịkwa", "description": "Gaa n'ihu na oku ngwaọrụ sava na-achịkwa ndị na-abụghị streaming ruo mgbe model weghachiri nzaghachi onye ahịa nwere ike iji." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "Njikọ onye mmekọ", "dismissAriaLabel": "Wepụ" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Gbanyụọ imepụta ụdị ọkwa echiche dị iche iche (dịka -low, -medium, -high) na katalọgụ /v1/models." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Gbanyụọ imepụta ụdị ọkwa echiche dị iche iche (dịka -low, -medium, -high) na katalọgụ /v1/models.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index bc41744b5b..0423bbefe0 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Mostra gli id specchio claude/<provider>/<model> su /v1/models in modo che la scoperta dei modelli gateway di Claude Code elenchi i modelli non-Claude. Attenzione: raddoppia le voci nel catalogo per tutti i client quando abilitato globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Attiva corsie di ammissione virtuali adattive per tenant per l'invio ai provider (#9654): il picco di un tenant non restituisce più 503 a un altro. La variabile d'ambiente OMNIROUTE_CHAT_VIRTUAL_LANES ha la precedenza su questa impostazione della dashboard; le modifiche hanno effetto al riavvio del server.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Casa", "dashboard": "Pannello di controllo", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Consenti al ripristino del flusso di richiedere nuovamente la risposta e ricongiungerla dopo che i byte hanno già raggiunto il client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Includi campi con nomi descrittivi nelle risposte di /v1/models. Disabilita questa opzione per i client che accettano solo ID modello." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Abilita l'accesso alla rete nella sandbox delle skill." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rifiuta le richieste prima della spedizione quando il modello di destinazione manca delle capacità richieste (visione, strumenti, output strutturato, finestra di contesto). Protegge le richieste dirette a singolo fornitore che bypassano il filtro di compatibilità del livello combinato.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Pagina non trovata", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c18f7d9ffa..cc32747df4 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models で Claude Code ゲートウェイのモデル発見リストに非 Claude モデルを表示するために、claude/<provider>/<model> ミラー ID を広告します。警告: グローバルに有効にすると、すべてのクライアントのカタログエントリが重複します。", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "プロバイダーへのディスパッチ用に、テナントごとの適応型仮想受付レーンを有効にします(#9654):あるテナントのバーストが他のテナントに503を返さなくなります。OMNIROUTE_CHAT_VIRTUAL_LANES環境変数はこのダッシュボード設定より優先されます。変更はサーバー再起動時に反映されます。", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ホーム", "dashboard": "ダッシュボード", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Enable network access in the skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ディスパッチ前にリクエストを拒否します。ターゲットモデルに必要な機能(ビジョン、ツール、構造化出力、コンテキストウィンドウ)が欠けている場合。コンボレイヤーの互換性フィルターをバイパスする直接の単一プロバイダーリクエストを保護します。", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "ページが見つかりません", diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index c47cb2f420..da8081dfd2 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "სერვერის მიერ მართული ხელსაწყოების ციკლი", "description": "გააგრძელეთ სერვერის მიერ მართული ხელსაწყოების არასტრიმინგული გამოძახებები, სანამ მოდელი კლიენტისთვის გამოსაყენებელ პასუხს არ დააბრუნებს." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "პარტნიორის ბმული", "dismissAriaLabel": "დახურვა" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "გამორთეთ აზროვნების დონის ვარიანტების (მაგ. -low, -medium, -high) გენერირება /v1/models კატალოგში." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "გამორთეთ აზროვნების დონის ვარიანტების (მაგ. -low, -medium, -high) გენერირება /v1/models კატალოგში.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 458baf9e35..e6ceb04f35 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "ផ្សព្វផ្សាយ mirror ids របស់ claude/<provider>/<model> នៅលើ /v1/models ដើម្បីឱ្យការស្វែងរកម៉ូដែលតាម gateway របស់ Claude Code រាយបញ្ជីម៉ូដែលដែលមិនមែនជា Claude។ ការព្រមាន៖ វានឹងបង្កើនធាតុក្នុងកាតាឡុកទ្វេដងសម្រាប់ client ទាំងអស់ នៅពេលបើកជាសកល។", "featureFlagNoThinkingAliasEnabledDescription": "កុងតាក់មេសម្រាប់ gateway aliases របស់ no-think/<provider>/<model>។ បើក (លំនាំដើម)៖ /v1/models ផ្សព្វផ្សាយវ៉ារ្យ៉ង់មិនគិតសម្រាប់គ្រប់ម៉ូដែល Claude ដែលមានសមត្ថភាពគិត និងមានលក្ខណៈសម្បត្តិគ្រប់គ្រាន់ ហើយ no-think/ id ដែលបានផ្ញើក្នុងសំណើ នឹងត្រូវដោះស្រាយត្រឡប់ទៅម៉ូដែលពិត ដោយបិទការវែកញែក។ បិទ៖ គ្មានវ៉ារ្យ៉ង់ណាមួយត្រូវបានផ្សព្វផ្សាយទេ ហើយ no-think/ id ត្រូវបានចាត់ទុកដូចជា model id មិនស្គាល់ផ្សេងទៀត។ ការជ្រើសរើសបើក/បិទ ModelSpec.noThinkingAlias សម្រាប់ម៉ូដែលនីមួយៗ នៅតែអនុវត្ត ខណៈដែលវាត្រូវបានបើក។", "featureFlagChatVirtualLanesEnabledDescription": "បើកផ្លូវចូលនិម្មិតដែលសម្របខ្លួនតាម tenant នីមួយៗ សម្រាប់ការបញ្ជូនទៅ provider (#9654)៖ ការកើនឡើងខ្លាំងភ្លាមៗរបស់ tenant មួយ នឹងលែងបណ្ដាលឱ្យ tenant មួយទៀតទទួល 503។ env var OMNIROUTE_CHAT_VIRTUAL_LANES មានអាទិភាពលើការកំណត់ជំនួសពី dashboard នេះ ហើយការផ្លាស់ប្ដូរនឹងមានប្រសិទ្ធភាពនៅពេលចាប់ផ្ដើម server ឡើងវិញ។", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ទំព័រដើម", "dashboard": "ផ្ទាំងគ្រប់គ្រង", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "អនុញ្ញាតឱ្យការសង្គ្រោះ stream ស្នើសុំ response ម្តងទៀត និងភ្ជាប់វាបន្ត បន្ទាប់ពី bytes បានទៅដល់ client រួចហើយ។" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "រួមបញ្ចូលវាលឈ្មោះដែលងាយស្រួលបង្ហាញក្នុង response របស់ /v1/models។ បិទវាសម្រាប់ clients ដែលទទួលយកតែ model IDs ប៉ុណ្ណោះ។" }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "រង្វិលជុំ Tool ដែលគ្រប់គ្រងដោយ Server", "description": "បន្តការហៅ tool ដែលគ្រប់គ្រងដោយ server ដោយមិនប្រើ streaming រហូតដល់ model ត្រឡប់ response ដែល client អាចប្រើបាន។" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "បដិសេធសំណើមុនពេលបញ្ជូន នៅពេលម៉ូដែលគោលដៅខ្វះសមត្ថភាពដែលត្រូវការ (ចក្ខុវិស័យ ឧបករណ៍ លទ្ធផលមានរចនាសម្ព័ន្ធ បង្អួចបរិបទ)។ វាការពារសំណើទៅកាន់អ្នកផ្តល់សេវាតែមួយដោយផ្ទាល់ ដែលរំលងតម្រងភាពត្រូវគ្នានៃស្រទាប់បន្សំ។", "featureFlagDisableContextWindowChecksDescription": "រំលងការត្រួតពិនិត្យបង្អួចបរិបទ និងចំនួនថូខិនបញ្ចូលអតិបរមាក្នុងមូលដ្ឋានរបស់ OmniRoute សម្រាប់សំណើទៅកាន់ម៉ូដែលតែមួយដោយផ្ទាល់។ អ្នកផ្តល់សេវាខាងលើនៅតែអនុវត្តដែនកំណត់ជាក់ស្តែងរបស់ពួកគេ។ ការបង្ហាប់ប្រូម និងដែនកំណត់ថូខិនលទ្ធផលនៅតែដំណើរការ។", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "រកមិនឃើញទំព័រ", diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index 4f0fc6e096..91f2848d9f 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "claude/<provider>/<model> ಮಿರರ್ ಐಡಿಗಳನ್ನು /v1/models ನಲ್ಲಿ ಪ್ರಕಟಿಸಿ, ಆದ್ದರಿಂದ Claude Code gateway ಮಾಡೆಲ್ ಆವಿಷ್ಕಾರವು non-Claude ಮಾಡೆಲ್ಗಳನ್ನು ಪಟ್ಟಿಮಾಡುತ್ತದೆ. ಎಚ್ಚರಿಕೆ: ಜಾಗತಿಕವಾಗಿ ಸಕ್ರಿಯಗೊಳಿಸಿದಾಗ ಎಲ್ಲಾ ಕ್ಲೈಂಟ್ಗಳಿಗೆ ಕ್ಯಾಟಲಾಗ್ ಎಂಟ್ರಿಗಳನ್ನು ದ್ವಿಗುಣಗೊಳಿಸುತ್ತದೆ.", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> gateway ಅಲಿಯಾಸ್ಗಳಿಗಾಗಿ ಮಾಸ್ಟರ್ ಸ್ವಿಚ್. ಆನ್ (ಡೀಫಾಲ್ಟ್): /v1/models ಪ್ರತಿ ಅರ್ಹ ಥಿಂಕಿಂಗ್-ಸಾಮರ್ಥ್ಯ Claude ಮಾಡೆಲ್ಗಾಗಿ ನೋ-ಥಿಂಕಿಂಗ್ ವೇರಿಯಂಟ್ ಅನ್ನು ಪ್ರಕಟಿಸುತ್ತದೆ, ಮತ್ತು ವಿನಂತಿಯಲ್ಲಿ ಕಳುಹಿಸಿದ no-think/ ಐಡಿಯು ಕಾರಣವನ್ನು ಅಡಗಿಸಿ ನಿಜವಾದ ಮಾಡೆಲ್ಗೆ ಪರಿಹರಿಸುತ್ತದೆ. ಆಫ್: ಯಾವುದೇ ವೇರಿಯಂಟ್ಗಳನ್ನು ಪ್ರಕಟಿಸಲಾಗುವುದಿಲ್ಲ ಮತ್ತು no-think/ ಐಡಿಯನ್ನು ಯಾವುದೇ ಅಜ್ಞಾತ ಮಾಡೆಲ್ ಐಡಿಯಂತೆ ಪರಿಗಣಿಸಲಾಗುತ್ತದೆ. ಈ ಆನ್ ಆಗಿರುವಾಗ ಪ್ರತಿ-ಮಾಡೆಲ್ ModelSpec.noThinkingAlias ಆಯ್ಕೆ-ಆನ್/ಆಫ್ ಇನ್ನೂ ಅನ್ವಯಿಸುತ್ತದೆ.", "featureFlagChatVirtualLanesEnabledDescription": "ಪ್ರೊವೈಡರ್ ಡಿಸ್ಪ್ಯಾಚ್ ಗಾಗಿ ಪ್ರತಿ-ಟೆನಂಟ್ ಅಡಾಪ್ಟಿವ್ ವರ್ಚುವಲ್ ಅಡ್ಮಿಷನ್ ಲೇನ್ಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಿ (#9654): ಒಂದು ಟೆನಂಟ್ನ ಬರ್ಸ್ಟ್ ಇನ್ನು ಮುಂದೆ ಮತ್ತೊಂದನ್ನು 503 ಮಾಡುವುದಿಲ್ಲ. OMNIROUTE_CHAT_VIRTUAL_LANES ಎನ್ವಿ ವೇರಿಯಬಲ್ ಈ ಡ್ಯಾಶ್ಬೋರ್ಡ್ ಓವರ್ರೈಡ್ ಮೇಲೆ ಗೆಲ್ಲುತ್ತದೆ; ಬದಲಾವಣೆಗಳು ಸರ್ವರ್ ರೀಸ್ಟಾರ್ಟ್ ನಲ್ಲಿ ಜಾರಿಗೆ ಬರುತ್ತವೆ.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ಹೋಮ್", "dashboard": "ಡ್ಯಾಶ್ಬೋರ್ಡ್", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ಬೈಟ್ಗಳು ಈಗಾಗಲೇ ಕ್ಲೈಂಟ್ ಅನ್ನು ತಲುಪಿದ ನಂತರವೂ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಮತ್ತೊಮ್ಮೆ ವಿನಂತಿಸಿ, ಅದನ್ನು ಜೋಡಿಸಲು ಸ್ಟ್ರೀಮ್ ಮರುಪಡೆಯುವಿಕೆಗೆ ಅನುಮತಿಸಿ." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ಪ್ರತಿಕ್ರಿಯೆಗಳಲ್ಲಿ ಪ್ರದರ್ಶನಕ್ಕೆ ಸೂಕ್ತವಾದ ಹೆಸರು ಕ್ಷೇತ್ರಗಳನ್ನು ಸೇರಿಸಿ. ಕೇವಲ ಮಾದರಿ IDಗಳನ್ನು ಸ್ವೀಕರಿಸುವ ಕ್ಲೈಂಟ್ಗಳಿಗಾಗಿ ಇದನ್ನು ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಿ." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "ಸರ್ವರ್-ಸ್ವಾಮ್ಯದ ಪರಿಕರ ಲೂಪ್", "description": "ಮಾದರಿಯು ಕ್ಲೈಂಟ್ಗೆ ಬಳಸಬಹುದಾದ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಹಿಂದಿರುಗಿಸುವವರೆಗೆ ಸ್ಟ್ರೀಮಿಂಗ್ ಅಲ್ಲದ ಸರ್ವರ್-ಸ್ವಾಮ್ಯದ ಪರಿಕರ ಕರೆಗಳನ್ನು ಮುಂದುವರಿಸಿ." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ಗುರಿ ಮಾದರಿಯಲ್ಲಿ ಅಗತ್ಯ ಸಾಮರ್ಥ್ಯಗಳು (ದೃಷ್ಟಿ, ಪರಿಕರಗಳು, ರಚನಾತ್ಮಕ ಔಟ್ಪುಟ್, ಸಂದರ್ಭ ವಿಂಡೋ) ಇಲ್ಲದಿದ್ದಾಗ ರವಾನಿಸುವ ಮೊದಲು ವಿನಂತಿಗಳನ್ನು ತಿರಸ್ಕರಿಸಿ. ಇದು ಕಾಂಬೊ-ಲೇಯರ್ ಹೊಂದಾಣಿಕೆ ಫಿಲ್ಟರ್ ಅನ್ನು ತಪ್ಪಿಸುವ ನೇರ ಏಕ-ಪೂರೈಕೆದಾರ ವಿನಂತಿಗಳನ್ನು ರಕ್ಷಿಸುತ್ತದೆ.", "featureFlagDisableContextWindowChecksDescription": "ನೇರ ಏಕ-ಮಾದರಿ ವಿನಂತಿಗಳಿಗಾಗಿ OmniRoute ನ ಸ್ಥಳೀಯ ಸಂದರ್ಭ-ವಿಂಡೋ ಮತ್ತು ಗರಿಷ್ಠ-ಇನ್ಪುಟ್-ಟೋಕನ್ ಪರಿಶೀಲನೆಯನ್ನು ಬಿಟ್ಟುಬಿಡಿ. ಅಪ್ಸ್ಟ್ರೀಮ್ ಪೂರೈಕೆದಾರರು ತಮ್ಮ ನೈಜ ಮಿತಿಗಳನ್ನು ಇನ್ನೂ ಜಾರಿಗೊಳಿಸುತ್ತಾರೆ. ಪ್ರಾಂಪ್ಟ್ ಸಂಕುಚನ ಮತ್ತು ಔಟ್ಪುಟ್-ಟೋಕನ್ ಮಿತಿಗಳು ಸಕ್ರಿಯವಾಗಿಯೇ ಇರುತ್ತವೆ.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "ಪುಟ ಕಂಡುಬಂದಿಲ್ಲ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 302795b997..8bb1a943af 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models에서 Claude Code 게이트웨이 모델 검색 목록에 비Claude 모델이 포함되도록 claude/<provider>/<model> 미러 ID를 광고합니다. 경고: 전역적으로 활성화하면 모든 클라이언트에 대해 카탈로그 항목이 두 배로 증가합니다.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "공급자 디스패치를 위해 테넌트별 적응형 가상 승인 레인을 활성화합니다(#9654): 한 테넌트의 폭증이 더 이상 다른 테넌트에 503을 반환하지 않습니다. OMNIROUTE_CHAT_VIRTUAL_LANES 환경 변수가 이 대시보드 설정보다 우선하며, 변경 사항은 서버 재시작 시 적용됩니다.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "홈", "dashboard": "대시보드", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "바이트가 이미 클라이언트에 도달한 후에도 스트림 복구가 응답을 다시 요청하고 이어 붙일 수 있도록 허용합니다." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models 응답에 표시용 이름 필드를 포함합니다. 모델 ID만 허용하는 클라이언트의 경우 이 설정을 비활성화하세요." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "스킬 샌드박스에서 네트워크 액세스를 활성화합니다." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "대상 모델에 필수 기능(비전, 도구, 구조화된 출력, 컨텍스트 창)이 부족할 경우 요청을 발송 전에 거부합니다. 콤보 레이어 호환성 필터를 우회하는 직접 단일 공급자 요청을 보호합니다.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "페이지를 찾을 수 없습니다", diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index fffffe2b89..30b12f3c8e 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Skelbti claude/<provider>/<model> atspindžio ID per /v1/models, kad Claude Code šliuzo modelių aptikimas rodytų ir ne-Claude modelius. Įspėjimas: įjungus visuotinai, katalogo įrašų skaičius padvigubėja visiems klientams.", "featureFlagNoThinkingAliasEnabledDescription": "Pagrindinis no-think/<provider>/<model> šliuzo aliasų jungiklis. Įjungta (numatyta): /v1/models skelbia „no-thinking“ variantą kiekvienam tinkamam mąstymo galimybę turinčiam Claude modeliui, o užklausoje pateiktas no-think/ ID nukreipiamas atgal į tikrąjį modelį su slopintu samprotavimu. Išjungta: variantai nėra skelbiami, o no-think/ ID laikomas kaip bet koks kitas nežinomas modelio ID. Kol tai įjungta, vis tiek taikomas kiekvieno modelio ModelSpec.noThinkingAlias sutikimo/atsisakymo nustatymas.", "featureFlagChatVirtualLanesEnabledDescription": "Įjungti kiekvienam nuomotojui pritaikomas adaptyvias virtualias priėmimo juostas teikėjų siuntimui (#9654): vieno nuomotojo srautas nebesukelia 503 klaidos kitam. Aplinkos kintamasis OMNIROUTE_CHAT_VIRTUAL_LANES turi pirmenybę prieš šį skydelio nustatymą; pakeitimai įsigalioja po serverio paleidimo iš naujo.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Pradžia", "dashboard": "Suvestinė", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Leisti atkuriant srautą dar kartą paprašyti atsakymo ir jį sujungti, net jei klientas jau gavo dalį baitų." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Į /v1/models atsakymus įtraukti patogiam rodymui skirtus pavadinimų laukus. Išjunkite tai klientams, kurie priima tik modelių ID." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Serverio valdomas įrankių ciklas", "description": "Tęsti serverio valdomus nesrautinius įrankių iškvietimus, kol modelis pateiks klientui tinkamą atsakymą." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Atmesti užklausas prieš jas perduodant, kai tikslinis modelis neturi reikiamų galimybių (vaizdų apdorojimo, įrankių, struktūrizuotos išvesties, konteksto lango). Tai apsaugo tiesiogines vienam teikėjui skirtas užklausas, kurios apeina derinių lygmens suderinamumo filtrą.", "featureFlagDisableContextWindowChecksDescription": "Tiesioginėms vieno modelio užklausoms praleisti OmniRoute vietinę konteksto lango ir didžiausio įvesties žetonų skaičiaus patikrą. Išoriniai teikėjai vis tiek taiko savo faktinius apribojimus. Raginimų glaudinimas ir išvesties žetonų apribojimai lieka aktyvūs.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Puslapis nerastas", diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 4e4c7ea39b..eac45c511c 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Izziņot claude/<provider>/<model> spoguļa ID vietnē /v1/models, lai Claude Code vārtejas modeļu atrašana uzrādītu ne-Claude modeļus. Brīdinājums: kad iespējots globāli, dublējas katalogā ieraksti visiem klientiem.", "featureFlagNoThinkingAliasEnabledDescription": "Galvenais slēdzis no-think/<provider><model> vārtejas aizstājvārdiem. Iesl. (pēc noklusējuma): /v1/models izziņo bezdomāšanas variantu katram atbilstošajam domāšanas spējīgajam Claude modelim, un no-think/ ID, kas nosūtīts pieprasījumā, tiek atrisināts atpakaļ uz reālo modeli ar apspiestu argumentāciju. Izsl.: nekādi varianti netiek izziņoti, un no-think/ ID tiek uzskatīts par jebkuru citu nezināmu modeļa ID. Modeļa ModelSpec.noThinkingAlias iekļaušanās/izslēgšanās iespēja joprojām darbojas, kamēr šis ir iespējots.", "featureFlagChatVirtualLanesEnabledDescription": "Iespējot katra nomnieka adaptīvas virtuālās uzņemšanas joslas nodrošinātāju izsūtīšanai (#9654): viena nomnieka slodzes lēciens vairs neizraisa 503 kļūdu citam. OMNIROUTE_CHAT_VIRTUAL_LANES vides mainīgais ir prioritārāks par šo paneļa iestatījumu; izmaiņas stājas spēkā pēc servera pārstartēšanas.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Sākums", "dashboard": "Panelis", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ļaut straumes atkopšanai atkārtoti pieprasīt atbildi un pievienot to straumei pēc tam, kad baiti jau ir sasnieguši klientu." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Iekļaut lietotājam draudzīga attēlojamā nosaukuma laukus /v1/models atbildēs. Atspējojiet šo opciju klientiem, kas pieņem tikai modeļu ID." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Servera pārvaldīta rīku izsaukumu cilpa", "description": "Turpināt servera pārvaldītos rīku izsaukumus bez straumēšanas, līdz modelis atgriež klientam izmantojamu atbildi." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Noraidīt pieprasījumus pirms nosūtīšanas, ja mērķa modelim trūkst nepieciešamo iespēju (redze, rīki, strukturēta izvade, konteksta logs). Aizsargā tiešus viena pakalpojumu sniedzēja pieprasījumus, kas apiet combo-layer saderības filtru.", "featureFlagDisableContextWindowChecksDescription": "Izlaist OmniRoute lokālo konteksta loga un max-input-token pārbaudi tiešiem viena modeļa pieprasījumiem. Augšupējie pakalpojumu sniedzēji joprojām piemēro savus faktiskos ierobežojumus. Uzvedņu saspiešana un izvades marķieru ierobežojumi paliek aktīvi.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Lapa nav atrasta", diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 3ccf2ce103..cbb931d389 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code ഗേറ്റ്വേ മോഡൽ കണ്ടെത്തലിൽ Claude ഇതര മോഡലുകൾ പട്ടികപ്പെടുത്തുന്നതിനായി /v1/models-ൽ claude/<provider>/<model> മിറർ ഐഡികൾ പ്രസിദ്ധപ്പെടുത്തുക. മുന്നറിയിപ്പ്: ആഗോളതലത്തിൽ പ്രവർത്തനക്ഷമമാക്കുമ്പോൾ എല്ലാ ക്ലയന്റുകൾക്കുമുള്ള കാറ്റലോഗ് എൻട്രികളുടെ എണ്ണം ഇരട്ടിയാകും.", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ഗേറ്റ്വേ അപരനാമങ്ങൾക്കുള്ള മാസ്റ്റർ സ്വിച്ച്. ഓൺ (ഡിഫോൾട്ട്): യോഗ്യതയുള്ള, ചിന്താശേഷിയുള്ള ഓരോ Claude മോഡലിനും ചിന്തിക്കാത്ത ഒരു വകഭേദം /v1/models പ്രസിദ്ധപ്പെടുത്തും; കൂടാതെ അഭ്യർത്ഥനയിൽ അയയ്ക്കുന്ന no-think/ ഐഡി, റീസണിങ് അടിച്ചമർത്തിക്കൊണ്ട് യഥാർഥ മോഡലിലേക്ക് തിരികെ പരിഹരിക്കപ്പെടും. ഓഫ്: വകഭേദങ്ങളൊന്നും പ്രസിദ്ധപ്പെടുത്തില്ല; no-think/ ഐഡി മറ്റേതൊരു അജ്ഞാത മോഡൽ ഐഡിയെയും പോലെ പരിഗണിക്കും. ഇത് ഓണായിരിക്കുമ്പോഴും ഓരോ മോഡലിനുമുള്ള ModelSpec.noThinkingAlias ഓപ്റ്റ്-ഇൻ/ഓപ്റ്റ്-ഔട്ട് ബാധകമാണ്.", "featureFlagChatVirtualLanesEnabledDescription": "പ്രൊവൈഡർ ഡിസ്പാച്ചിനായി ഓരോ ടെനന്റിനും അനുയോജ്യമായി മാറുന്ന വെർച്വൽ അഡ്മിഷൻ ലെയിനുകൾ പ്രവർത്തനക്ഷമമാക്കുക (#9654): ഇനി ഒരു ടെനന്റിന്റെ പെട്ടെന്നുള്ള അഭ്യർത്ഥന വർധന മറ്റൊരാൾക്ക് 503 പിശക് സൃഷ്ടിക്കില്ല. ഈ ഡാഷ്ബോർഡ് ഓവർറൈഡിനേക്കാൾ OMNIROUTE_CHAT_VIRTUAL_LANES env var-ന് മുൻഗണനയുണ്ട്; സെർവർ പുനരാരംഭിക്കുമ്പോൾ മാറ്റങ്ങൾ പ്രാബല്യത്തിൽ വരും.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ഹോം", "dashboard": "ഡാഷ്ബോർഡ്", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ബൈറ്റുകൾ ഇതിനകം ക്ലയന്റിൽ എത്തിയതിനുശേഷവും പ്രതികരണം വീണ്ടും അഭ്യർത്ഥിച്ച് കൂട്ടിച്ചേർക്കാൻ സ്ട്രീം വീണ്ടെടുക്കലിനെ അനുവദിക്കുക." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models പ്രതികരണങ്ങളിൽ പ്രദർശനത്തിന് അനുയോജ്യമായ നാമ ഫീൽഡുകൾ ഉൾപ്പെടുത്തുക. മോഡൽ ID-കൾ മാത്രം സ്വീകരിക്കുന്ന ക്ലയന്റുകൾക്കായി ഇത് പ്രവർത്തനരഹിതമാക്കുക." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "സെർവർ ഉടമസ്ഥതയിലുള്ള ടൂൾ ലൂപ്പ്", "description": "മോഡൽ ക്ലയന്റിന് ഉപയോഗിക്കാവുന്ന പ്രതികരണം നൽകുന്നതുവരെ സ്ട്രീമിംഗ് അല്ലാത്ത, സെർവർ ഉടമസ്ഥതയിലുള്ള ടൂൾ കോളുകൾ തുടരുക." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ലക്ഷ്യമാക്കിയ മോഡലിന് ആവശ്യമായ ശേഷികൾ (വിഷൻ, ടൂളുകൾ, ഘടനാബദ്ധമായ ഔട്ട്പുട്ട്, കോൺടെക്സ്റ്റ് വിൻഡോ) ഇല്ലെങ്കിൽ അഭ്യർത്ഥനകൾ അയയ്ക്കുന്നതിന് മുമ്പ് നിരസിക്കുക. കോംബോ-ലെയർ അനുയോജ്യതാ ഫിൽട്ടർ മറികടക്കുന്ന നേരിട്ടുള്ള ഒറ്റ-പ്രൊവൈഡർ അഭ്യർത്ഥനകളെ ഇത് പരിരക്ഷിക്കുന്നു.", "featureFlagDisableContextWindowChecksDescription": "നേരിട്ടുള്ള ഒറ്റ-മോഡൽ അഭ്യർത്ഥനകൾക്കായി OmniRoute-ന്റെ ലോക്കൽ കോൺടെക്സ്റ്റ്-വിൻഡോ, പരമാവധി ഇൻപുട്ട്-ടോക്കൺ പരിശോധനകൾ ഒഴിവാക്കുക. അപ്സ്ട്രീം പ്രൊവൈഡർമാർ അവരുടെ യഥാർഥ പരിധികൾ തുടർന്നും നടപ്പാക്കും. പ്രോംപ്റ്റ് കംപ്രഷനും ഔട്ട്പുട്ട്-ടോക്കൺ പരിധികളും സജീവമായി തുടരും.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "പേജ് കണ്ടെത്തിയില്ല", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 27778d5ba6..3c6404eafd 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models वर claude/<provider>/<model> मिरर आयडीज जाहिरात करा जेणेकरून Claude Code गेटवे मॉडेल शोध सूचीमध्ये नॉन-Claude मॉडेल्स समाविष्ट होतील. चेतावणी: जागतिक स्तरावर सक्षम केल्यास सर्व क्लायंटसाठी कॅटलॉग नोंदी दुहेरी होतात.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "प्रदाता डिस्पॅचसाठी प्रति-टेनंट अनुकूली व्हर्च्युअल अॅडमिशन लेन सक्षम करा (#9654): एका टेनंटचा बर्स्ट यापुढे दुसऱ्या टेनंटला 503 देत नाही. OMNIROUTE_CHAT_VIRTUAL_LANES पर्यावरण चल या डॅशबोर्ड सेटिंगपेक्षा वरचढ आहे; बदल सर्व्हर रीस्टार्ट केल्यावर प्रभावी होतात.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "बाइट्स आधीच क्लायंटपर्यंत पोहोचल्यानंतर प्रतिसादाची पुन्हा विनंती करण्यासाठी आणि तो जोडण्यासाठी स्ट्रीम रिकव्हरीला अनुमती द्या." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिसादांमध्ये प्रदर्शनासाठी अनुकूल नाव फील्ड समाविष्ट करा. केवळ मॉडेल आयडी स्वीकारणाऱ्या क्लायंटसाठी हे अक्षम करा." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "स्किल्स सँडबॉक्समध्ये नेटवर्क ॲक्सेस सक्षम करा." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "डिस्पॅच करण्यापूर्वी विनंत्या नाकारल्या जातात जेव्हा लक्ष्य मॉडेल आवश्यक क्षमतांचा अभाव असतो (दृष्टी, साधने, संरचित आउटपुट, संदर्भ विंडो). कॉम्बो-लेयर सुसंगतता फिल्टरला बायपास करणाऱ्या थेट एकल-प्रदात्याच्या विनंत्यांचे संरक्षण करते.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "पृष्ठ सापडले नाही", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 0854540494..9523b29ec2 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Iklankan claude/<provider>/<model> mirror ids pada /v1/models supaya senarai penemuan model gerbang Claude Code termasuk model bukan Claude. Amaran: menggandakan entri katalog untuk semua klien apabila diaktifkan secara global.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktifkan lorong kemasukan maya adaptif setiap-tenant untuk penghantaran pembekal (#9654): lonjakan satu tenant tidak lagi memberikan 503 kepada tenant lain. Pemboleh ubah persekitaran OMNIROUTE_CHAT_VIRTUAL_LANES mengatasi tetapan papan pemuka ini; perubahan berkuat kuasa apabila pelayan dimulakan semula.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Rumah", "dashboard": "Papan pemuka", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Benarkan pemulihan strim untuk meminta respons semula dan mencantumkannya selepas bait telah sampai ke pelanggan." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sertakan medan nama mesra paparan dalam respons /v1/models. Nyahdayakan ini untuk pelanggan yang hanya menerima ID model." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Dayakan akses rangkaian dalam kotak pasir kemahiran." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tolak permintaan sebelum penghantaran apabila model sasaran tidak mempunyai keupayaan yang diperlukan (penglihatan, alat, output terstruktur, tetingkap konteks). Melindungi permintaan penyedia tunggal secara langsung yang mengabaikan penapis keserasian lapisan gabungan.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Halaman tidak ditemui", diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 5fbe8d926d..3c13aaaf21 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Uri l-IDs mera claude/<provider>/<model> fuq /v1/models sabiex l-iskoperta tal-mudelli mill-gateway ta' Claude Code telenka mudelli mhux ta' Claude. Twissija: meta din l-għażla tkun attivata globalment, tirdoppja l-entrati fil-katalgu għall-klijenti kollha.", "featureFlagNoThinkingAliasEnabledDescription": "Swiċċ ewlieni għall-aliases tal-gateway no-think/<provider>/<model>. Mixgħul (predefinit): /v1/models juri varjant mingħajr ħsieb għal kull mudell Claude eliġibbli li kapaċi jaħseb, u ID no-think/ mibgħut f'talba jiġi solvut lura għall-mudell reali bir-raġunament imrażżan. Mitfi: ma jintwera l-ebda varjant u ID no-think/ jiġi ttrattat bħal kull ID ieħor ta' mudell mhux magħruf. L-għażla ta' inklużjoni/esklużjoni ModelSpec.noThinkingAlias għal kull mudell tibqa' tapplika waqt li din l-għażla tkun mixgħula.", "featureFlagChatVirtualLanesEnabledDescription": "Ippermetti korsiji virtwali adattivi tad-dħul għal kull tenant għad-dispaċċ tal-fornituri (#9654): żieda f'daqqa fit-traffiku ta' tenant wieħed ma tibqax tikkawża żball 503 għal ieħor. Il-varjabbli tal-ambjent OMNIROUTE_CHAT_VIRTUAL_LANES jieħu preċedenza fuq din is-sovrasKitba tad-dashboard; il-bidliet jidħlu fis-seħħ meta jerġa' jinbeda s-server.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Paġna Ewlenija", "dashboard": "Dashboard", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ħalli l-irkupru tal-fluss jitlob it-tweġiba mill-ġdid u jgħaqqadha wara li l-bytes ikunu diġà waslu għand il-klijent." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkludi oqsma tal-isem adattati għall-wiri fit-tweġibiet ta’ /v1/models. Iddiżattiva dan għal klijenti li jaċċettaw biss IDs tal-mudelli." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Ċiklu tal-Għodod Immexxi mis-Server", "description": "Kompli s-sejħiet mhux streaming tal-għodod immexxija mis-server sakemm il-mudell jirritorna tweġiba li tista’ tintuża mill-klijent." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Irrifjuta t-talbiet qabel jintbagħtu meta l-mudell fil-mira ma jkollux il-kapaċitajiet meħtieġa (viżjoni, għodod, output strutturat, tieqa tal-kuntest). Jipproteġi t-talbiet diretti lil fornitur wieħed li jaqbżu l-filtru tal-kompatibbiltà tas-saff tal-kombinazzjonijiet.", "featureFlagDisableContextWindowChecksDescription": "Aqbeż il-verifika lokali ta' OmniRoute għat-tieqa tal-kuntest u l-għadd massimu ta' tokens tal-input għal talbiet diretti lil mudell wieħed. Il-fornituri upstream xorta jinfurzaw il-limiti effettivi tagħhom. Il-kompressjoni tal-prompt u l-limiti tat-tokens tal-output jibqgħu attivi.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Il-paġna ma nstabitx", diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 24aeaa4a07..d74f8bd325 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code gateway ၏ မော်ဒယ်ရှာဖွေမှုစာရင်းတွင် Claude မဟုတ်သော မော်ဒယ်များ ပါဝင်စေရန် /v1/models တွင် claude/<provider>/<model> mirror ids များကို ဖော်ပြပါ။ သတိပေးချက်- အားလုံးအတွက် ဖွင့်ထားပါက client အားလုံးတွင် catalog entry အရေအတွက် နှစ်ဆဖြစ်စေသည်။", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> gateway aliases များအတွက် အဓိကခလုတ်။ ဖွင့်ထားလျှင် (မူလသတ်မှတ်ချက်)- /v1/models သည် သတ်မှတ်ချက်ပြည့်မီသော စဉ်းစားဆင်ခြင်နိုင်သည့် Claude မော်ဒယ်တိုင်းအတွက် မစဉ်းစားသည့် မူကွဲတစ်ခုကို ဖော်ပြပြီး တောင်းဆိုမှုတစ်ခုတွင် ပေးပို့သော no-think/ id ကို reasoning ပိတ်ထားသည့် တကယ့်မော်ဒယ်သို့ ပြန်လည်ချိတ်ဆက်ပေးသည်။ ပိတ်ထားလျှင်- မည်သည့်မူကွဲကိုမျှ မဖော်ပြဘဲ no-think/ id ကို အခြားမသိသော model id များကဲ့သို့ သတ်မှတ်သည်။ ဤခလုတ်ဖွင့်ထားစဉ် မော်ဒယ်တစ်ခုချင်းစီ၏ ModelSpec.noThinkingAlias opt-in/opt-out သတ်မှတ်ချက်သည် ဆက်လက်သက်ရောက်သည်။", "featureFlagChatVirtualLanesEnabledDescription": "provider dispatch (#9654) အတွက် tenant တစ်ခုချင်းစီအလိုက် အလိုက်သင့်ပြောင်းလဲနိုင်သော virtual admission lanes များကို ဖွင့်ပါ။ tenant တစ်ခု၏ ရုတ်တရက်မြင့်တက်လာသော အသုံးပြုမှုကြောင့် အခြား tenant တွင် 503 ဖြစ်ပေါ်တော့မည်မဟုတ်ပါ။ OMNIROUTE_CHAT_VIRTUAL_LANES env var သည် ဤ dashboard override ထက် ဦးစားပေးသက်ရောက်ပြီး ပြောင်းလဲမှုများသည် server ပြန်လည်စတင်ချိန်တွင် အသက်ဝင်မည်ဖြစ်သည်။", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ပင်မစာမျက်နှာ", "dashboard": "ဒက်ရှ်ဘုတ်", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Byte များ client ထံ ရောက်ရှိပြီးနောက်တွင်ပင် stream recovery က response ကို ထပ်မံတောင်းခံ၍ ဆက်စပ်ပေါင်းစည်းနိုင်ရန် ခွင့်ပြုပါ။" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models response များတွင် ဖတ်ရှုရလွယ်ကူသော name field များကို ထည့်သွင်းပါ။ Model ID များကိုသာ လက်ခံသော client များအတွက် ၎င်းကို ပိတ်ပါ။" }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server ပိုင် Tool Loop", "description": "Model က client အသုံးပြုနိုင်သော response တစ်ခုကို ပြန်ပေးသည်အထိ non-streaming server-owned tool call များကို ဆက်လက်လုပ်ဆောင်ပါ။" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ပစ်မှတ်မော်ဒယ်တွင် လိုအပ်သော စွမ်းဆောင်ရည်များ (အမြင်၊ ကိရိယာများ၊ ဖွဲ့စည်းပုံကျ အထွက်၊ ကွန်တက်စ်ဝင်းဒိုး) မရှိပါက ဖြန့်ပို့ခြင်းမပြုမီ တောင်းဆိုမှုများကို ပယ်ချပါ။ ၎င်းသည် combo-layer လိုက်ဖက်ညီမှု စစ်ထုတ်မှုကို ကျော်လွှားသော တစ်ခုတည်းသောပံ့ပိုးသူထံ တိုက်ရိုက်တောင်းဆိုမှုများကို ကာကွယ်ပေးသည်။", "featureFlagDisableContextWindowChecksDescription": "တစ်ခုတည်းသောမော်ဒယ်ထံ တိုက်ရိုက်တောင်းဆိုမှုများအတွက် OmniRoute ၏ စက်တွင်း ကွန်တက်စ်ဝင်းဒိုးနှင့် အများဆုံးထည့်သွင်းတိုကင် စစ်ဆေးမှုကို ကျော်ပါ။ မူလပံ့ပိုးသူများက ၎င်းတို့၏ အမှန်တကယ်ကန့်သတ်ချက်များကို ဆက်လက်အတည်ပြုကျင့်သုံးမည်ဖြစ်သည်။ ပရောမ့်ချုံ့ခြင်းနှင့် အထွက်တိုကင် အများဆုံးကန့်သတ်ချက်များမှာ ဆက်လက်အသက်ဝင်နေမည်ဖြစ်သည်။", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "စာမျက်နှာကို ရှာမတွေ့ပါ", diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 12beceb056..08de787ae8 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code गेटवे मोडेल खोजले गैर-Claude मोडेलहरू सूचीबद्ध गरोस् भन्नका लागि /v1/models मा claude/<provider>/<model> मिरर id हरू देखाउनुहोस्। चेतावनी: विश्वव्यापी रूपमा सक्षम गर्दा सबै क्लाइन्टका लागि क्याटलग प्रविष्टिहरू दोब्बर हुन्छन्।", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> गेटवे एलियसहरूका लागि मुख्य स्विच। अन (पूर्वनिर्धारित): /v1/models ले हरेक योग्य सोच्न-सक्षम Claude मोडेलका लागि सोचाइ-विहीन भेरियन्ट देखाउँछ, र अनुरोधमा पठाइएको no-think/ id वास्तविक मोडेलमा फर्केर रिजोल्भ हुन्छ र रिजनिङ दबाइन्छ। अफ: कुनै पनि भेरियन्ट देखाइँदैन र no-think/ id लाई अन्य कुनै अज्ञात मोडेल id सरह व्यवहार गरिन्छ। यो अन हुँदा पनि प्रत्येक मोडेलको ModelSpec.noThinkingAlias अप्ट-इन/अप्ट-आउट लागू हुन्छ।", "featureFlagChatVirtualLanesEnabledDescription": "प्रदायक डिस्प्याच (#9654) का लागि प्रत्येक टेनेन्टअनुसार अनुकूल हुने भर्चुअल एडमिसन लेनहरू सक्षम गर्नुहोस्: अब एउटा टेनेन्टको अचानक बढेको ट्राफिकले अर्कोलाई 503 गराउँदैन। OMNIROUTE_CHAT_VIRTUAL_LANES env var ले यस ड्यासबोर्ड ओभरराइडभन्दा प्राथमिकता पाउँछ; परिवर्तनहरू सर्भर पुनः सुरु भएपछि लागू हुन्छन्।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "गृहपृष्ठ", "dashboard": "ड्यासबोर्ड", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "बाइटहरू क्लाइन्टसम्म पुगिसकेपछि पनि स्ट्रिम पुनर्प्राप्तिलाई प्रतिक्रिया पुनः अनुरोध गरेर जोड्न अनुमति दिनुहोस्।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिक्रियाहरूमा प्रदर्शनमैत्री नाम फिल्डहरू समावेश गर्नुहोस्। मोडेल ID मात्र स्वीकार गर्ने क्लाइन्टहरूका लागि यसलाई अक्षम गर्नुहोस्।" }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "सर्भर-स्वामित्वको उपकरण लूप", "description": "मोडेलले क्लाइन्टले प्रयोग गर्न मिल्ने प्रतिक्रिया नफर्काएसम्म नन-स्ट्रिमिङ सर्भर-स्वामित्वका उपकरण कलहरू जारी राख्नुहोस्।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "लक्षित मोडेलमा आवश्यक क्षमताहरू (भिजन, उपकरणहरू, संरचित आउटपुट, कन्टेक्स्ट विन्डो) नभएमा डिस्प्याच गर्नुअघि अनुरोधहरू अस्वीकार गर्नुहोस्। यसले कम्बो-लेयरको अनुकूलता फिल्टरलाई बाइपास गर्ने प्रत्यक्ष एकल-प्रदायक अनुरोधहरूलाई सुरक्षित गर्छ।", "featureFlagDisableContextWindowChecksDescription": "प्रत्यक्ष एकल-मोडेल अनुरोधहरूका लागि OmniRoute को स्थानीय कन्टेक्स्ट-विन्डो र अधिकतम-इनपुट-टोकन जाँच छोड्नुहोस्। अपस्ट्रिम प्रदायकहरूले अझै पनि आफ्ना वास्तविक सीमाहरू लागू गर्छन्। प्रम्प्ट कम्प्रेसन र आउटपुट-टोकन सीमाहरू सक्रिय रहन्छन्।", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "पृष्ठ फेला परेन", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 436f67a31f..2ec3eae52e 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Adverteer claude/<provider>/<model> spiegel-id's op /v1/models zodat Claude Code gateway modelontdekking niet-Claude modellen vermeldt. Waarschuwing: dubbele catalogusvermeldingen voor alle klanten wanneer wereldwijd ingeschakeld.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Schakel adaptieve virtuele toegangsbanen per tenant in voor provider-dispatch (#9654): een piek van de ene tenant geeft de andere niet langer een 503. De omgevingsvariabele OMNIROUTE_CHAT_VIRTUAL_LANES wint het van deze dashboard-instelling; wijzigingen gaan in bij een serverherstart.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Thuis", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Sta streamherstel toe om de respons opnieuw aan te vragen en samen te voegen nadat bytes de client al hebben bereikt." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Voeg weergavevriendelijke naamvelden toe aan /v1/models-responsen. Schakel dit uit voor clients die alleen model-ID's accepteren." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Schakel netwerktoegang in de skills-sandbox in." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Weiger verzoeken vóór verzending wanneer het doellmodel ontbrekende vereiste mogelijkheden heeft (zicht, tools, gestructureerde output, contextvenster). Beschermt directe verzoeken van een enkele aanbieder die de compatibiliteitsfilter van de comb-laag omzeilen.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Pagina niet gevonden", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 141764faa5..09784ae247 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamer claude/<provider>/<model> speil-id-er på /v1/models slik at Claude Code gateway-modelloppdagelse viser ikke-Claude-modeller. Advarsel: dobler katalogoppføringer for alle klienter når det er aktivert globalt.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktiver adaptive virtuelle tilgangsfelt per tenant for leverandørdistribusjon (#9654): et utbrudd fra én tenant gir ikke lenger en annen 503. Miljøvariabelen OMNIROUTE_CHAT_VIRTUAL_LANES overstyrer denne innstillingen i dashbordet; endringer trer i kraft ved omstart av serveren.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Hjem", "dashboard": "Dashbord", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Tillat strømgjenoppretting å be om svaret på nytt og sy det sammen etter at bytes allerede har nådd klienten." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkluder visningsvennlige navnefelt i /v1/models-svar. Deaktiver dette for klienter som kun godtar modell-ID-er." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktiver nettverkstilgang i ferdighetssandkassen." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Avvis forespørselene før utsendelse når målmodellen mangler nødvendige funksjoner (visjon, verktøy, strukturert utdata, kontekstvindu). Beskytter direkte forespørseler fra enkeltleverandører som omgår kombinasjonslagets kompatibilitetsfilter.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Siden ble ikke funnet", diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index cdf0e66af3..b3de2f2763 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/modelsରେ claude/<provider>/<model> ମିରର୍ IDଗୁଡ଼ିକ ପ୍ରକାଶ କରନ୍ତୁ, ଯାହାଦ୍ୱାରା Claude Code ଗେଟୱେ ମଡେଲ୍ ଆବିଷ୍କାରରେ Claude ବ୍ୟତୀତ ଅନ୍ୟ ମଡେଲ୍ଗୁଡ଼ିକ ତାଲିକାଭୁକ୍ତ ହେବ। ଚେତାବନୀ: ବିଶ୍ୱବ୍ୟାପୀ ଭାବେ ସକ୍ଷମ କଲେ ଏହା ସମସ୍ତ କ୍ଲାଏଣ୍ଟ ପାଇଁ କ୍ୟାଟାଲଗ୍ ଏଣ୍ଟ୍ରି ସଂଖ୍ୟାକୁ ଦ୍ୱିଗୁଣିତ କରେ।", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ଗେଟୱେ ଉପନାମଗୁଡ଼ିକ ପାଇଁ ମୁଖ୍ୟ ସ୍ୱିଚ୍। ଚାଲୁ (ଡିଫଲ୍ଟ): /v1/models ପ୍ରତ୍ୟେକ ଯୋଗ୍ୟ ବିଚାର-ସକ୍ଷମ Claude ମଡେଲ୍ ପାଇଁ ଏକ ବିଚାର-ବିହୀନ ଭାର୍ସନ୍ ପ୍ରକାଶ କରେ, ଏବଂ ଅନୁରୋଧରେ ପଠାଯାଇଥିବା no-think/ ID ବିଚାର ପ୍ରକ୍ରିୟାକୁ ଦମନ କରି ପ୍ରକୃତ ମଡେଲ୍କୁ ପୁନଃ ସମାଧାନ ହୁଏ। ବନ୍ଦ: କୌଣସି ଭାର୍ସନ୍ ପ୍ରକାଶ କରାଯାଏ ନାହିଁ ଏବଂ no-think/ IDକୁ ଅନ୍ୟ ଯେକୌଣସି ଅଜଣା ମଡେଲ୍ ID ପରି ବିବେଚନା କରାଯାଏ। ଏହା ଚାଲୁ ଥିବାବେଳେ ମଧ୍ୟ ପ୍ରତି-ମଡେଲ୍ ModelSpec.noThinkingAlias ଅପ୍ଟ-ଇନ୍/ଅପ୍ଟ-ଆଉଟ୍ ପ୍ରଯୁଜ୍ୟ ହୁଏ।", "featureFlagChatVirtualLanesEnabledDescription": "ପ୍ରଦାତା ଡିସ୍ପାଚ୍ (#9654) ପାଇଁ ପ୍ରତି-ଟେନାଣ୍ଟ ଅନୁକୂଳନଶୀଳ ଭର୍ଚୁଆଲ୍ ଆଡମିଶନ୍ ଲେନ୍ଗୁଡ଼ିକ ସକ୍ଷମ କରନ୍ତୁ: ଗୋଟିଏ ଟେନାଣ୍ଟର ହଠାତ୍ ଟ୍ରାଫିକ୍ ବୃଦ୍ଧି ଆଉ ଅନ୍ୟ ଟେନାଣ୍ଟ ପାଇଁ 503 ତ୍ରୁଟି ସୃଷ୍ଟି କରିବ ନାହିଁ। OMNIROUTE_CHAT_VIRTUAL_LANES env var ଏହି ଡ୍ୟାସ୍ବୋର୍ଡ ଓଭର୍ରାଇଡ୍ଠାରୁ ପ୍ରାଥମିକତା ପାଏ; ସର୍ଭର୍ ପୁନଃଚାଳନ ପରେ ପରିବର୍ତ୍ତନଗୁଡ଼ିକ କାର୍ଯ୍ୟକାରୀ ହୁଏ।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ମୂଳପୃଷ୍ଠା", "dashboard": "ଡ୍ୟାସ୍ବୋର୍ଡ", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ବାଇଟ୍ଗୁଡ଼ିକ କ୍ଲାଏଣ୍ଟ ପାଖରେ ପହଞ୍ଚିସାରିବା ପରେ ମଧ୍ୟ ଷ୍ଟ୍ରିମ୍ ପୁନରୁଦ୍ଧାରକୁ ପୁନର୍ବାର ପ୍ରତିକ୍ରିୟା ଅନୁରୋଧ କରି ତାହାକୁ ଯୋଡ଼ିବା ପାଇଁ ଅନୁମତି ଦିଅନ୍ତୁ।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ପ୍ରତିକ୍ରିୟାଗୁଡ଼ିକରେ ପ୍ରଦର୍ଶନ-ଅନୁକୂଳ ନାମ ଫିଲ୍ଡଗୁଡ଼ିକୁ ସାମିଲ କରନ୍ତୁ। କେବଳ ମଡେଲ୍ ID ଗ୍ରହଣ କରୁଥିବା କ୍ଲାଏଣ୍ଟମାନଙ୍କ ପାଇଁ ଏହାକୁ ଅକ୍ଷମ କରନ୍ତୁ।" }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "ସର୍ଭର-ମାଲିକାନାଧୀନ ଟୁଲ୍ ଲୁପ୍", "description": "ମଡେଲ୍ ଏକ କ୍ଲାଏଣ୍ଟ-ବ୍ୟବହାରଯୋଗ୍ୟ ପ୍ରତିକ୍ରିୟା ଫେରାଇବା ପର୍ଯ୍ୟନ୍ତ ନନ୍-ଷ୍ଟ୍ରିମିଂ ସର୍ଭର-ମାଲିକାନାଧୀନ ଟୁଲ୍ କଲ୍ଗୁଡ଼ିକୁ ଜାରି ରଖନ୍ତୁ।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ଲକ୍ଷ୍ୟ ମଡେଲ୍ରେ ଆବଶ୍ୟକ କ୍ଷମତାଗୁଡ଼ିକ (ଭିଜନ୍, ଟୁଲ୍, ଷ୍ଟ୍ରକ୍ଚର୍ଡ ଆଉଟପୁଟ୍, କଣ୍ଟେକ୍ସ୍ଟ ୱିଣ୍ଡୋ) ନଥିଲେ ଡିସ୍ପାଚ୍ ପୂର୍ବରୁ ଅନୁରୋଧଗୁଡ଼ିକୁ ପ୍ରତ୍ୟାଖ୍ୟାନ କରନ୍ତୁ। ଏହା କମ୍ବୋ-ଲେୟର୍ ସୁସଙ୍ଗତତା ଫିଲ୍ଟର୍କୁ ବାଇପାସ୍ କରୁଥିବା ସିଧାସଳଖ ଏକକ-ପ୍ରଦାନକାରୀ ଅନୁରୋଧଗୁଡ଼ିକୁ ସୁରକ୍ଷିତ କରେ।", "featureFlagDisableContextWindowChecksDescription": "ସିଧାସଳଖ ଏକକ-ମଡେଲ୍ ଅନୁରୋଧଗୁଡ଼ିକ ପାଇଁ OmniRouteର ସ୍ଥାନୀୟ କଣ୍ଟେକ୍ସ୍ଟ-ୱିଣ୍ଡୋ ଏବଂ ସର୍ବାଧିକ-ଇନପୁଟ୍-ଟୋକନ୍ ଯାଞ୍ଚକୁ ଏଡ଼ାନ୍ତୁ। ଅପ୍ଷ୍ଟ୍ରିମ୍ ପ୍ରଦାନକାରୀମାନେ ତଥାପି ସେମାନଙ୍କର ପ୍ରକୃତ ସୀମାଗୁଡ଼ିକୁ ଲାଗୁ କରିବେ। ପ୍ରମ୍ପ୍ଟ କମ୍ପ୍ରେସନ୍ ଏବଂ ଆଉଟପୁଟ୍-ଟୋକନ୍ ସୀମା ସକ୍ରିୟ ରହିବ।", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "ପୃଷ୍ଠା ମିଳିଲା ନାହିଁ", diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 51145d3716..399da303c0 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models ਉੱਤੇ claude/<provider>/<model> ਮਿਰਰ IDs ਦਾ ਪ੍ਰਚਾਰ ਕਰੋ, ਤਾਂ ਜੋ Claude Code ਗੇਟਵੇ ਮਾਡਲ ਖੋਜ ਵਿੱਚ ਗੈਰ-Claude ਮਾਡਲ ਸੂਚੀਬੱਧ ਹੋਣ। ਚੇਤਾਵਨੀ: ਗਲੋਬਲ ਤੌਰ 'ਤੇ ਸਮਰੱਥ ਕਰਨ 'ਤੇ ਇਹ ਸਾਰੇ ਕਲਾਇੰਟਾਂ ਲਈ ਕੈਟਾਲਾਗ ਐਂਟਰੀਆਂ ਨੂੰ ਦੁੱਗਣਾ ਕਰ ਦਿੰਦਾ ਹੈ।", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ਗੇਟਵੇ ਉਪਨਾਮਾਂ ਲਈ ਮੁੱਖ ਸਵਿੱਚ। ਚਾਲੂ (ਡਿਫਾਲਟ): /v1/models ਹਰ ਯੋਗ, ਸੋਚਣ-ਸਮਰੱਥ Claude ਮਾਡਲ ਲਈ ਇੱਕ ਬਿਨਾਂ-ਸੋਚ ਵਾਲਾ ਰੂਪ ਦਰਸਾਉਂਦਾ ਹੈ, ਅਤੇ ਬੇਨਤੀ ਵਿੱਚ ਭੇਜਿਆ ਗਿਆ no-think/ ID ਤਰਕ ਨੂੰ ਦਬਾ ਕੇ ਮੁੜ ਅਸਲ ਮਾਡਲ ਵਿੱਚ ਹੱਲ ਹੁੰਦਾ ਹੈ। ਬੰਦ: ਕੋਈ ਰੂਪ ਦਰਸਾਏ ਨਹੀਂ ਜਾਂਦੇ ਅਤੇ no-think/ ID ਨੂੰ ਕਿਸੇ ਹੋਰ ਅਣਜਾਣ ਮਾਡਲ ID ਵਾਂਗ ਮੰਨਿਆ ਜਾਂਦਾ ਹੈ। ਜਦੋਂ ਇਹ ਚਾਲੂ ਹੋਵੇ, ਤਾਂ ਪ੍ਰਤੀ-ਮਾਡਲ ModelSpec.noThinkingAlias ਔਪਟ-ਇਨ/ਔਪਟ-ਆਉਟ ਫਿਰ ਵੀ ਲਾਗੂ ਹੁੰਦਾ ਹੈ।", "featureFlagChatVirtualLanesEnabledDescription": "ਪ੍ਰਦਾਤਾ ਡਿਸਪੈਚ (#9654) ਲਈ ਪ੍ਰਤੀ-ਟੈਨੈਂਟ ਅਨੁਕੂਲ ਵਰਚੁਅਲ ਐਡਮਿਸ਼ਨ ਲੇਨ ਸਮਰੱਥ ਕਰੋ: ਹੁਣ ਇੱਕ ਟੈਨੈਂਟ ਦਾ ਅਚਾਨਕ ਵਧਿਆ ਲੋਡ ਦੂਜੇ ਲਈ 503 ਪੈਦਾ ਨਹੀਂ ਕਰੇਗਾ। OMNIROUTE_CHAT_VIRTUAL_LANES env var ਨੂੰ ਇਸ ਡੈਸ਼ਬੋਰਡ ਓਵਰਰਾਈਡ ਉੱਤੇ ਤਰਜੀਹ ਮਿਲਦੀ ਹੈ; ਤਬਦੀਲੀਆਂ ਸਰਵਰ ਮੁੜ ਚਾਲੂ ਹੋਣ 'ਤੇ ਲਾਗੂ ਹੁੰਦੀਆਂ ਹਨ।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "ਮੁੱਖ ਪੰਨਾ", "dashboard": "ਡੈਸ਼ਬੋਰਡ", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ਬਾਈਟਾਂ ਦੇ ਕਲਾਇੰਟ ਤੱਕ ਪਹੁੰਚ ਜਾਣ ਤੋਂ ਬਾਅਦ ਵੀ ਸਟ੍ਰੀਮ ਰਿਕਵਰੀ ਨੂੰ ਜਵਾਬ ਦੁਬਾਰਾ ਮੰਗਣ ਅਤੇ ਉਸਨੂੰ ਜੋੜਨ ਦੀ ਇਜਾਜ਼ਤ ਦਿਓ।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ਜਵਾਬਾਂ ਵਿੱਚ ਪ੍ਰਦਰਸ਼ਨ-ਅਨੁਕੂਲ ਨਾਮ ਫੀਲਡਾਂ ਸ਼ਾਮਲ ਕਰੋ। ਸਿਰਫ਼ ਮਾਡਲ IDs ਸਵੀਕਾਰ ਕਰਨ ਵਾਲੇ ਕਲਾਇੰਟਾਂ ਲਈ ਇਸਨੂੰ ਅਸਮਰੱਥ ਕਰੋ।" }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "ਸਰਵਰ-ਮਲਕੀਅਤ ਵਾਲਾ ਟੂਲ ਲੂਪ", "description": "ਸਰਵਰ-ਮਲਕੀਅਤ ਵਾਲੀਆਂ ਗੈਰ-ਸਟ੍ਰੀਮਿੰਗ ਟੂਲ ਕਾਲਾਂ ਨੂੰ ਉਦੋਂ ਤੱਕ ਜਾਰੀ ਰੱਖੋ ਜਦੋਂ ਤੱਕ ਮਾਡਲ ਕਲਾਇੰਟ ਲਈ ਵਰਤਣਯੋਗ ਜਵਾਬ ਨਾ ਦੇਵੇ।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ਜਦੋਂ ਟਾਰਗੇਟ ਮਾਡਲ ਵਿੱਚ ਲੋੜੀਂਦੀਆਂ ਸਮਰੱਥਾਵਾਂ (ਵਿਜ਼ਨ, ਟੂਲ, ਸਟ੍ਰਕਚਰਡ ਆਉਟਪੁੱਟ, ਕਾਂਟੈਕਸਟ ਵਿੰਡੋ) ਨਾ ਹੋਣ, ਤਾਂ ਡਿਸਪੈਚ ਤੋਂ ਪਹਿਲਾਂ ਬੇਨਤੀਆਂ ਅਸਵੀਕਾਰ ਕਰੋ। ਇਹ ਉਹਨਾਂ ਸਿੱਧੀਆਂ ਸਿੰਗਲ-ਪ੍ਰੋਵਾਈਡਰ ਬੇਨਤੀਆਂ ਦੀ ਸੁਰੱਖਿਆ ਕਰਦਾ ਹੈ ਜੋ ਕੌਂਬੋ-ਲੇਅਰ ਅਨੁਕੂਲਤਾ ਫਿਲਟਰ ਨੂੰ ਬਾਈਪਾਸ ਕਰਦੀਆਂ ਹਨ।", "featureFlagDisableContextWindowChecksDescription": "ਸਿੱਧੀਆਂ ਸਿੰਗਲ-ਮਾਡਲ ਬੇਨਤੀਆਂ ਲਈ OmniRoute ਦੀ ਸਥਾਨਕ ਕਾਂਟੈਕਸਟ-ਵਿੰਡੋ ਅਤੇ ਅਧਿਕਤਮ-ਇਨਪੁੱਟ-ਟੋਕਨ ਜਾਂਚ ਨੂੰ ਛੱਡੋ। ਅੱਪਸਟ੍ਰੀਮ ਪ੍ਰੋਵਾਈਡਰ ਫਿਰ ਵੀ ਆਪਣੀਆਂ ਅਸਲ ਸੀਮਾਵਾਂ ਲਾਗੂ ਕਰਦੇ ਹਨ। ਪ੍ਰੌਂਪਟ ਕੰਪ੍ਰੈਸ਼ਨ ਅਤੇ ਆਉਟਪੁੱਟ-ਟੋਕਨ ਸੀਮਾਵਾਂ ਸਰਗਰਮ ਰਹਿੰਦੀਆਂ ਹਨ।", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "ਪੰਨਾ ਨਹੀਂ ਮਿਲਿਆ", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 51bf3f513a..86ff74d38c 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "I-anunsyo ang claude/<provider>/<model> mirror ids sa /v1/models upang ang Claude Code gateway model discovery ay maglista ng mga non-Claude models. Babala: nagdodoble ng catalog entries para sa lahat ng kliyente kapag pinagana nang globally.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Paganahin ang adaptive virtual admission lanes para sa bawat tenant sa pagpapadala ng provider (#9654): ang pag-akyat ng trapiko ng isang tenant ay hindi na nagbibigay ng 503 sa iba. Ang environment variable na OMNIROUTE_CHAT_VIRTUAL_LANES ay mas nangingibabaw sa setting na ito sa dashboard; magkakabisa ang mga pagbabago sa pag-restart ng server.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Bahay", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Payagan ang pagbawi ng stream na hilingin muli ang tugon at pagdugtungin ito pagkatapos makarating na ang mga byte sa client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Isama ang mga display-friendly na field ng pangalan sa mga tugon ng /v1/models. I-disable ito para sa mga client na tumatanggap lamang ng mga model ID." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "I-enable ang access sa network sa skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tanggihan ang mga kahilingan bago ang pagpapadala kapag ang target na modelo ay kulang sa mga kinakailangang kakayahan (paningin, mga tool, nakabalangkas na output, bintana ng konteksto). Pinoprotektahan ang mga direktang kahilingan mula sa isang tagapagbigay na lumalampas sa filter ng pagiging tugma ng combo-layer.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Hindi matagpuan ang pahina", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 6de7a9c3d2..8dc1d43f63 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamuj identyfikatory luster claude/<provider>/<model> na /v1/models, aby brama modelu Claude Code wyświetlała listę modeli niebędących Claude. Uwaga: podwaja wpisy w katalogu dla wszystkich klientów, gdy jest włączone globalnie.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Włącz adaptacyjne wirtualne pasma przyjęć dla każdego tenanta przy wysyłce do dostawców (#9654): przeciążenie jednego tenanta nie powoduje już błędu 503 u innego. Zmienna środowiskowa OMNIROUTE_CHAT_VIRTUAL_LANES ma pierwszeństwo przed tym ustawieniem w panelu; zmiany wchodzą w życie po restarcie serwera.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Strona główna", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Zezwalaj na odzyskiwanie strumienia w celu ponownego zażądania odpowiedzi i połączenia jej po tym, jak bajty dotarły już do klienta." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Dołączaj przyjazne do wyświetlania pola nazw w odpowiedziach /v1/models. Wyłącz tę opcję dla klientów, którzy akceptują tylko identyfikatory modeli." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Włącz dostęp do sieci w piaskownicy umiejętności." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Odrzuć żądania przed wysyłką, gdy docelowy model nie ma wymaganych możliwości (wizja, narzędzia, strukturalne wyjście, okno kontekstowe). Chroni bezpośrednie żądania od pojedynczego dostawcy, które omijają filtr zgodności warstwy kombinacyjnej.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Nie znaleziono strony", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index a0c44ce426..e847f282db 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -993,6 +993,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Divulgar ids espelho claude/<provider>/<model> em /v1/models para que a descoberta de modelos do gateway Claude Code liste modelos não-Claude. Atenção: duplica as entradas do catálogo para todos os clientes quando ativado globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Ative faixas de admissão virtuais adaptativas por tenant para o despacho de provedores (#9654): o pico de um tenant não gera mais 503 para outro. A variável de ambiente OMNIROUTE_CHAT_VIRTUAL_LANES tem precedência sobre esta configuração do painel; as alterações entram em vigor ao reiniciar o servidor.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Início", "dashboard": "Painel", @@ -12985,6 +12986,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Permite que a recuperação de stream solicite a resposta novamente e a costure depois que bytes já chegaram ao cliente." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inclui campos de nome amigável para exibição nas respostas de /v1/models. Desative isso para clientes que aceitam apenas IDs de modelo." }, @@ -13021,6 +13025,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server-Owned Tool Loop", "description": "Continue chamadas de ferramentas do servidor (server-owned) em não-streaming até que o modelo retorne uma resposta utilizável pelo cliente." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13634,6 +13646,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisições antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saída estruturada, janela de contexto). Protege requisições diretas que ignoram o filtro de compatibilidade do combo.", "featureFlagDisableContextWindowChecksDescription": "Ignora a verificação local do OmniRoute para janela de contexto e limite máximo de tokens de entrada em solicitações diretas a um único modelo. Os provedores upstream continuam aplicando seus limites reais. A compactação de prompts e os limites de tokens de saída permanecem ativos.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Página não encontrada", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 48992b62ab..18e075cd32 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -993,6 +993,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Anuncie os ids de espelho claude/<provider>/<model> em /v1/models para que a descoberta de modelos do gateway Claude Code liste modelos não Claude. Aviso: duplica entradas de catálogo para todos os clientes quando ativado globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Ative filas de admissão virtuais adaptativas por tenant para o encaminhamento de fornecedores (#9654): um pico de tráfego de um tenant já não gera 503 noutro. A variável de ambiente OMNIROUTE_CHAT_VIRTUAL_LANES sobrepõe-se a esta definição do painel; as alterações entram em vigor ao reiniciar o servidor.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Página inicial", "dashboard": "Painel", @@ -12978,6 +12979,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Permitir que a recuperação de stream solicite a resposta novamente e a junte após os bytes já terem chegado ao cliente." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Incluir campos de nome fáceis de ler nas respostas de /v1/models. Desative isto para clientes que aceitam apenas IDs de modelo." }, @@ -13010,6 +13014,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Ativar o acesso à rede na sandbox de competências." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13623,6 +13635,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rejeitar pedidos antes do envio quando o modelo de destino não tiver as capacidades necessárias (visão, ferramentas, saída estruturada, janela de contexto). Protege pedidos diretos de um único fornecedor que contornam o filtro de compatibilidade da camada combinada.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Página não encontrada", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 63bea8addc..b1654f46c5 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Publica id-urile mirror claude/<provider>/<model> pe /v1/models astfel încât lista de descoperire a modelului Claude Code să includă modele non-Claude. Atenție: dublează intrările din catalog pentru toți clienții când este activat global.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Activați benzile de admitere virtuale adaptive per-tenant pentru expedierea către furnizori (#9654): un vârf de trafic al unui tenant nu mai returnează 503 altui tenant. Variabila de mediu OMNIROUTE_CHAT_VIRTUAL_LANES are prioritate față de această setare din panou; modificările intră în vigoare la repornirea serverului.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Acasă", "dashboard": "Tabloul de bord", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Permite recuperării fluxului să solicite din nou răspunsul și să îl îmbine după ce octeții au ajuns deja la client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include câmpuri de nume ușor de afișat în răspunsurile /v1/models. Dezactivează această opțiune pentru clienții care acceptă doar ID-uri de model." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Activează accesul la rețea în sandbox-ul de abilități." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Respinge cererile înainte de expediere atunci când modelul țintă nu are capabilitățile necesare (viziune, instrumente, ieșire structurată, fereastră de context). Protejează cererile directe de un singur furnizor care ocolesc filtrul de compatibilitate al stratului combinat.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Pagina nu a fost găsită", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 22aa6ba84e..4503907861 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Публиковать claude/<провайдер>/<модель> зеркальные ID на /v1/models, чтобы Claude Code мог видеть не-Claude модели. Внимание: удваивает записи каталога для всех клиентов при глобальном включении.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Включите адаптивные виртуальные полосы допуска для каждого тенанта при маршрутизации к провайдерам (#9654): всплеск нагрузки одного тенанта больше не вызывает 503 у другого. Переменная окружения OMNIROUTE_CHAT_VIRTUAL_LANES имеет приоритет над этой настройкой в панели; изменения вступают в силу после перезапуска сервера.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Главная", "dashboard": "Панель управления", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Enable network access in the skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Отклонять запросы перед отправкой, когда целевая модель не имеет необходимых возможностей (визуализация, инструменты, структурированный вывод, контекстное окно). Защищает прямые запросы от единственного поставщика, которые обходят фильтр совместимости комбинированного слоя.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Страница не найдена", diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 18d09ef82f..2d8e40b949 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code ද්වාරයේ මාදිලි සොයාගැනීමේදී Claude නොවන මාදිලි ලැයිස්තුගත කිරීම සඳහා /v1/models හි claude/<provider>/<model> කැඩපත් හැඳුනුම් ප්රචාරය කරන්න. අවවාදයයි: ගෝලීයව සබල කළ විට සියලු සේවාලාභීන් සඳහා නාමාවලි ඇතුළත් කිරීම් දෙගුණ වේ.", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ද්වාර අන්වර්ථ සඳහා ප්රධාන ස්විචය. සක්රියයි (පෙරනිමිය): /v1/models මඟින් සුදුසුකම් ඇති, සිතා බැලීමේ හැකියාව සහිත සෑම Claude මාදිලියකටම සිතා බැලීමෙන් තොර ප්රභේදයක් ප්රචාරය කරන අතර, ඉල්ලීමක් සමඟ යවන no-think/ හැඳුනුමක් තර්කනය යටපත් කර සැබෑ මාදිලිය වෙත නැවත විසඳයි. අක්රියයි: කිසිදු ප්රභේදයක් ප්රචාරය නොකරන අතර no-think/ හැඳුනුමක් වෙනත් ඕනෑම නොදන්නා මාදිලි හැඳුනුමක් මෙන් සලකයි. මෙය සක්රියව තිබියදීත් එක් එක් මාදිලියට අදාළ ModelSpec.noThinkingAlias තෝරා සක්රිය කිරීම/අක්රිය කිරීම තවදුරටත් අදාළ වේ.", "featureFlagChatVirtualLanesEnabledDescription": "සපයන්නා වෙත යැවීම සඳහා එක් එක් ටෙනන්ට්ට අනුව අනුවර්තනය වන අතථ්ය ප්රවේශ මංතීරු සබල කරන්න (#9654): එක් ටෙනන්ට් කෙනෙකුගේ හදිසි ඉල්ලීම් වැඩිවීමක් තවදුරටත් වෙනත් අයෙකුට 503 දෝෂයක් ඇති නොකරයි. OMNIROUTE_CHAT_VIRTUAL_LANES පරිසර විචල්යය මෙම උපකරණ පුවරු අතික්රමණයට වඩා ප්රමුඛ වේ; වෙනස්කම් සේවාදායකය නැවත ආරම්භ කළ විට ක්රියාත්මක වේ.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "මුල් පිටුව", "dashboard": "උපකරණ පුවරුව", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "බයිට් දැනටමත් සේවාලාභියා වෙත ළඟා වූ පසුවත් ප්රතිචාරය නැවත ඉල්ලා එය සම්බන්ධ කිරීමට ප්රවාහ ප්රතිසාධනයට ඉඩ දෙන්න." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ප්රතිචාරවල ප්රදර්ශනයට හිතකර නාම ක්ෂේත්ර ඇතුළත් කරන්න. ආකෘති ID පමණක් පිළිගන්නා සේවාලාභීන් සඳහා මෙය අක්රිය කරන්න." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "සේවාදායකය සතු මෙවලම් ලූපය", "description": "ආකෘතිය සේවාලාභියාට භාවිත කළ හැකි ප්රතිචාරයක් ලබා දෙන තෙක් ප්රවාහ නොවන, සේවාදායකය සතු මෙවලම් ඇමතුම් දිගටම කරගෙන යන්න." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ඉලක්ක මාදිලියට අවශ්ය හැකියාවන් (දෘශ්ය, මෙවලම්, ව්යුහගත ප්රතිදානය, සන්දර්භ කවුළුව) නොමැති විට යැවීමට පෙර ඉල්ලීම් ප්රතික්ෂේප කරන්න. මෙය සංයෝජන-ස්තර අනුකූලතා පෙරහන මඟහරින සෘජු තනි-සැපයුම්කරු ඉල්ලීම් ආරක්ෂා කරයි.", "featureFlagDisableContextWindowChecksDescription": "සෘජු තනි-මාදිලි ඉල්ලීම් සඳහා OmniRoute හි දේශීය සන්දර්භ-කවුළු සහ උපරිම-ආදාන-ටෝකන පරීක්ෂාව මඟහරින්න. උඩුගං සැපයුම්කරුවන් තවමත් ඔවුන්ගේ සැබෑ සීමාවන් බලාත්මක කරයි. ප්රේරක සම්පීඩනය සහ ප්රතිදාන-ටෝකන සීමා සක්රියව පවතී.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "පිටුව හමු නොවීය", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 5da7c79959..48dfa3b005 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Inzerujte claude/<provider>/<model> zrkadlové ID na /v1/models, aby zoznam objavovania modelov Claude Code obsahoval aj modely, ktoré nie sú Claude. Upozornenie: pri globálnom povolení zdvojuje záznamy v katalógu pre všetkých klientov.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Povoľte adaptívne virtuálne vstupné pruhy pre každého nájomcu (tenant) pri odosielaní poskytovateľom (#9654): špička jedného nájomcu už nespôsobí 503 u iného. Premenná prostredia OMNIROUTE_CHAT_VIRTUAL_LANES má prednosť pred týmto nastavením v riadiacom paneli; zmeny sa prejavia po reštarte servera.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Domov", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Povoliť obnovenie streamu na opätovné vyžiadanie odpovede a jej spojenie po tom, čo bajty už dorazili ku klientovi." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Zahrnúť polia s používateľsky prívetivými názvami v odpovediach /v1/models. Zakážte túto možnosť pre klientov, ktorí prijímajú iba ID modelov." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Povoliť sieťový prístup v sandboxe zručností." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Zamietnuť požiadavky pred odoslaním, keď cieľový model postráda požadované schopnosti (vízia, nástroje, štruktúrovaný výstup, kontextové okno). Chráni priamu požiadavku od jedného poskytovateľa, ktorá obchádza filter kompatibility kombinovanej vrstvy.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Stránka nenájdená", diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 89cc39e71e..c493b570c1 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Objavi zrcalne ID-je claude/<provider>/<model> na /v1/models, da odkrivanje modelov prehoda Claude Code prikaže tudi modele, ki niso Claude. Opozorilo: če je možnost omogočena globalno, se število vnosov v katalogu podvoji za vse odjemalce.", "featureFlagNoThinkingAliasEnabledDescription": "Glavno stikalo za vzdevke prehoda no-think/<provider>/<model>. Vklopljeno (privzeto): /v1/models objavi različico brez razmišljanja za vsak primeren model Claude, ki podpira razmišljanje, ID no-think/, poslan v zahtevi, pa se razreši nazaj v pravi model z onemogočenim sklepanjem. Izklopljeno: različice niso objavljene, ID no-think/ pa se obravnava kot kateri koli drug neznan ID modela. Ko je ta možnost vklopljena, še vedno velja nastavitev ModelSpec.noThinkingAlias za prijavo/odjavo posameznega modela.", "featureFlagChatVirtualLanesEnabledDescription": "Omogoči prilagodljive navidezne sprejemne pasove za posameznega najemnika pri posredovanju ponudniku (#9654): nenaden porast zahtev enega najemnika ne povzroča več napak 503 pri drugem. Spremenljivka okolja OMNIROUTE_CHAT_VIRTUAL_LANES ima prednost pred to nastavitvijo nadzorne plošče; spremembe začnejo veljati po ponovnem zagonu strežnika.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Domov", "dashboard": "Nadzorna plošča", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Obnovi toka omogoči, da znova zahteva odgovor in ga sestavi, potem ko so bajti že dosegli odjemalca." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "V odgovore /v1/models vključi uporabniku prijazna polja z imeni. To onemogočite za odjemalce, ki sprejemajo samo ID-je modelov." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Strežniško upravljana zanka orodij", "description": "Nadaljuj nestreamne strežniško upravljane klice orodij, dokler model ne vrne odziva, uporabnega za odjemalca." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Zavrni zahteve pred posredovanjem, ko ciljni model nima zahtevanih zmogljivosti (vid, orodja, strukturiran izhod, kontekstno okno). Ščiti neposredne zahteve za enega ponudnika, ki obidejo filter združljivosti kombinacijskega sloja.", "featureFlagDisableContextWindowChecksDescription": "Preskoči lokalno preverjanje kontekstnega okna in največjega števila vhodnih žetonov v OmniRoute za neposredne zahteve za posamezen model. Ponudniki v zaledju še vedno uveljavljajo svoje dejanske omejitve. Stiskanje pozivov in omejitve izhodnih žetonov ostanejo aktivni.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Strani ni mogoče najti", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 4609abce4d..5335baf092 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Оглашавај claude/<provider>/<model> mirror идентификаторе на /v1/models тако да Claude Code gateway откривање модела приказује и модели који нису Claude. Упозорење: дуплира ставке каталога за све клијенте када је омогућено глобално.", "featureFlagNoThinkingAliasEnabledDescription": "Главни прекидач за no-think/<provider>/<model> gateway алиасе. Укључено (подразумевано): /v1/models оглашава варијанту без размишљања за сваки подобан Claude модел способан за размишљање, а идентификатор no-think/ послат у захтеву се разрешава на стварни модел са потиснутим резоновањем. Искључено: варијанте се не оглашавају, а идентификатор no-think/ се третира као и сваки други непознат идентификатор модела. Опција по моделу ModelSpec.noThinkingAlias за укључивање/искључивање се и даље примењује док је ово укључено.", "featureFlagChatVirtualLanesEnabledDescription": "Омогући по-закупцу адаптивне виртуелне линије пријема за расподелу провајдера (#9654): нагли скок захтева једног закупца више не изазива 503 грешку код другог. Env варијабла OMNIROUTE_CHAT_VIRTUAL_LANES има приоритет над овим прекидачем у контролној табли; промене се примењују при поновном покретању сервера.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Почетна", "dashboard": "Контролна табла", @@ -12984,6 +12985,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Дозволи опоравку тока да поново затражи одговор и споји га након што су бајтови већ стигли до клијента." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Укључи поља са именом прилагођеним за приказ у одговорима /v1/models. Онемогући ово за клијенте који прихватају само ID-ове модела." }, @@ -13020,6 +13024,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Серверска петља алата", "description": "Наставите нестримујуће серверске позиве алата док модел не врати одговор који клијент може да користи." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13645,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Одбиј захтеве пре слања када циљни модел не поседује потребне могућности (визуелни унос, алати, структурирани излаз, контекстни прозор). Штити директне захтеве ка појединачном добављачу који заобилазе филтер компатибилности комбо-слоја.", "featureFlagDisableContextWindowChecksDescription": "Прескочи локалну провера контекстног прозора и максималног броја улазних токена OmniRoute-а за директне захтеве ка појединачном моделу. Добављачи услуга и даље примењују своја стварна ограничења. Компресија упита и ограничења излазних токена остају активни.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Страница није пронађена", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 8735d3ee6a..af5e6a7249 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamera claude/<provider>/<model> spegel-id på /v1/models så att Claude Code gateway-modellens upptäcktslista visar icke-Claude-modeller. Varning: dubblerar katalogposter för alla klienter när det är aktiverat globalt.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktivera adaptiva virtuella åtkomstfiler per tenant för providerutskick (#9654): en tenants burst ger inte längre en annan 503. Miljövariabeln OMNIROUTE_CHAT_VIRTUAL_LANES har företräde framför den här inställningen i instrumentpanelen; ändringarna träder i kraft vid omstart av servern.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Hem", "dashboard": "Instrumentpanel", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Tillåt strömåterställning att begära svaret igen och sammanfoga det efter att byte redan har nått klienten." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkludera visningsvänliga namnfält i svar från /v1/models. Inaktivera detta för klienter som endast accepterar modell-ID:n." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktivera nätverksåtkomst i kompetenssandlådan." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Avvisa förfrågningar innan de skickas när målmodellen saknar nödvändiga funktioner (vision, verktyg, strukturerad utdata, kontextfönster). Skyddar direkta förfrågningar från en enda leverantör som kringgår kompatibilitetsfiltret för kombinationslager.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Sidan kunde inte hittas", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index f4ccf25ebd..6fdc8c4b57 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Tangaza claude/<provider>/<model> vitambulisho vya kioo kwenye /v1/models ili orodha ya kugundua modeli za Claude Code iwe na modeli zisizo za Claude. Onyo: inafanya kuingia mara mbili kwenye katalogi kwa wateja wote inapowekwa kuwa ya ulimwengu mzima.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Washa njia za uandikishaji pepe zinazobadilika kwa kila mpangaji (tenant) kwa utumaji wa watoa huduma (#9654): mlipuko wa mpangaji mmoja hautoi tena 503 kwa mwingine. Kigezo cha mazingira cha OMNIROUTE_CHAT_VIRTUAL_LANES kinashinda mpangilio huu wa dashibodi; mabadiliko yanatumika wakati seva inapoanzishwa upya.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ruhusu urejesho wa mkondo kuomba jibu tena na kuliunganisha baada ya baiti kuwa tayari zimefikia mteja." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Jumuisha sehemu za majina rahisi kuonyeshwa katika majibu ya /v1/models. Zima hii kwa wateja wanaokubali vitambulisho vya mfano pekee." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Wezesha ufikiaji wa mtandao katika sandbox ya ujuzi." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "kataa maombi kabla ya kutuma wakati mfano wa lengo hauna uwezo unaohitajika (maono, zana, matokeo yaliyoandikwa, dirisha la muktadha). Inalinda maombi ya moja kwa moja kutoka kwa mtoa huduma mmoja ambayo yanapita chujio cha ulinganifu wa safu ya mchanganyiko.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Ukurasa haukupatikana", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 83c6a6151d..dc2d2582eb 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models இல் Claude Code gateway மாதிரி கண்டுபிடிப்பு பட்டியலில் non-Claude மாதிரிகளை காட்ட Claude/<provider>/<model> மின்னூல் அடையாளங்களை விளம்பரம் செய்யவும். எச்சரிக்கை: உலகளாவியமாக செயல்படுத்தப்பட்டால் அனைத்து கிளையன்டுகளுக்கும் பட்டியல் பதிவுகளை இரட்டைப்படுத்துகிறது.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "வழங்குநர் அனுப்பீட்டிற்கு ஒவ்வொரு குத்தகைதாரருக்கும் (tenant) தகவமைப்பு மெய்நிகர் சேர்க்கைப் பாதைகளை இயக்கு (#9654): ஒரு குத்தகைதாரரின் அதிகரிப்பு இனி மற்றொருவருக்கு 503 ஐ அளிக்காது. OMNIROUTE_CHAT_VIRTUAL_LANES சூழல் மாறி இந்த டாஷ்போர்டு அமைப்பை விட முன்னுரிமை பெறுகிறது; மாற்றங்கள் சேவையகம் மறுதொடக்கத்தில் நடைமுறைக்கு வரும்.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "பைட்டுகள் ஏற்கனவே கிளையண்டை அடைந்த பிறகும், பதிலை மீண்டும் கோரவும் அதை இணைக்கவும் ஸ்ட்ரீம் மீட்டெடுப்பை அனுமதிக்கவும்." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models பதில்களில் காட்சிக்கு ஏற்ற பெயர் புலங்களைச் சேர்க்கவும். மாடல் ஐடிகளை மட்டுமே ஏற்கும் கிளையண்டுகளுக்கு இதை முடக்கவும்." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "skills சாண்ட்பாக்ஸில் நெட்வொர்க் அணுகலை இயக்கவும்." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "விருப்பமான மாதிரி தேவையான திறன்களை (காணல், கருவிகள், கட்டமைக்கப்பட்ட வெளியீடு, சூழல் ஜன்னல்) இன்றி இருந்தால், அனுப்புவதற்கு முன் கோரிக்கைகளை நிராகரிக்கவும். கம்போ-லேயர் ஒத்திசைவு வடிகட்டியை தவிர்க்கும் நேரடி ஒற்றை வழங்குநர் கோரிக்கைகளை பாதுகாக்கிறது.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "பக்கம் கிடைக்கவில்லை", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 9d263d3c46..9ef01639cf 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models లో claude/<provider>/<model> మిర్రర్ ఐడీలను ప్రచారం చేయండి కాబట్టి Claude Code గేట్వే మోడల్ డిస్కవరీ non-Claude మోడళ్లను జాబితా చేస్తుంది. హెచ్చరిక: ఇది ప్రపంచవ్యాప్తంగా ప్రారంభించినప్పుడు అన్ని క్లయింట్ల కోసం కాటలాగ్ ఎంట్రీలను డబుల్ చేస్తుంది.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "ప్రొవైడర్ డిస్పాచ్ కోసం ప్రతి-టెనెంట్ అడాప్టివ్ వర్చువల్ అడ్మిషన్ లేన్లను ప్రారంభించండి (#9654): ఒక టెనెంట్ బర్స్ట్ ఇకపై మరొక టెనెంట్కు 503 ఇవ్వదు. OMNIROUTE_CHAT_VIRTUAL_LANES ఎన్విరాన్మెంట్ వేరియబుల్ ఈ డాష్బోర్డ్ సెట్టింగ్ కంటే ప్రాధాన్యత పొందుతుంది; మార్పులు సర్వర్ పునఃప్రారంభంలో ప్రభావం చూపుతాయి.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "బైట్లు ఇప్పటికే క్లయింట్‌కు చేరిన తర్వాత కూడా ప్రతిస్పందనను మళ్లీ అభ్యర్థించడానికి మరియు దానిని జత చేయడానికి స్ట్రీమ్ రికవరీని అనుమతించండి." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ప్రతిస్పందనలలో ప్రదర్శనకు అనుకూలమైన పేరు ఫీల్డ్‌లను చేర్చండి. మోడల్ IDలను మాత్రమే ఆమోదించే క్లయింట్‌ల కోసం దీనిని నిలిపివేయండి." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "స్కిల్స్ శాండ్‌బాక్స్‌లో నెట్‌వర్క్ యాక్సెస్‌ను ప్రారంభించండి." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ప్రయోజనాలు అవసరమైన సామర్థ్యాలు (దృష్టి, సాధనాలు, నిర్మిత అవుట్‌పుట్, సందర్భం విండో) లేని లక్ష్య మోడల్ ముందు పంపిణీకి అభ్యర్థనలను తిరస్కరించండి. కాంబో-లేయర్ అనుకూలత ఫిల్టర్‌ను దాటించే ప్రత్యక్ష సింగిల్-ప్రొవైడర్ అభ్యర్థనలను రక్షిస్తుంది.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "పేజీ కనుగొనబడలేదు", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index c86a8da842..dd1632b851 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "โฆษณา claude/<provider>/<model> mirror ids บน /v1/models เพื่อให้รายการการค้นหาโมเดลของ Claude Code แสดงโมเดลที่ไม่ใช่ Claude เตือน: จะทำให้มีรายการในแคตตาล็อกซ้ำสำหรับลูกค้าทุกคนเมื่อเปิดใช้งานทั่วโลก.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "เปิดใช้เลนรับเข้าเสมือนแบบปรับตัวต่อเทนแนนต์สำหรับการส่งไปยังผู้ให้บริการ (#9654): การพุ่งสูงของเทนแนนต์หนึ่งจะไม่ทำให้อีกเทนแนนต์ได้รับ 503 อีกต่อไป ตัวแปรสภาพแวดล้อม OMNIROUTE_CHAT_VIRTUAL_LANES มีผลเหนือการตั้งค่าแดชบอร์ดนี้ การเปลี่ยนแปลงมีผลเมื่อรีสตาร์ทเซิร์ฟเวอร์", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "บ้าน", "dashboard": "แดชบอร์ด", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "อนุญาตให้การกู้คืนสตรีมขอรับการตอบกลับอีกครั้งและต่อข้อมูลเข้าด้วยกันหลังจากที่ไบต์ไปถึงไคลเอนต์แล้ว" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "รวมฟิลด์ชื่อที่แสดงผลได้ง่ายในการตอบกลับ /v1/models ปิดใช้งานตัวเลือกนี้สำหรับไคลเอนต์ที่ยอมรับเฉพาะ ID โมเดลเท่านั้น" }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "เปิดใช้งานการเข้าถึงเครือข่ายใน skills sandbox" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ปฏิเสธคำขอก่อนการส่งเมื่อโมเดลเป้าหมายขาดความสามารถที่จำเป็น (วิสัยทัศน์, เครื่องมือ, ผลลัพธ์ที่มีโครงสร้าง, หน้าต่างบริบท) ป้องกันคำขอจากผู้ให้บริการเดียวที่ข้ามตัวกรองความเข้ากันได้ของเลเยอร์รวม", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "ไม่พบหน้า", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 3a8bcaa0b8..cf8b325b8b 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models üzerinde claude/<provider>/<model> ayna kimliklerini tanıtın, böylece Claude Code geçidi model keşfi, Claude olmayan modelleri listeler. Uyarı: Küresel olarak etkinleştirildiğinde tüm istemciler için katalog girişlerini iki katına çıkarır.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Sağlayıcı gönderimi için kiracı başına uyarlanabilir sanal kabul şeritlerini etkinleştirin (#9654): bir kiracının ani yükü artık diğerinde 503 hatasına neden olmaz. OMNIROUTE_CHAT_VIRTUAL_LANES ortam değişkeni bu panel ayarına göre önceliklidir; değişiklikler sunucu yeniden başlatıldığında geçerli olur.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Ana Sayfa", "dashboard": "Kontrol Paneli", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Baytlar istemciye ulaştıktan sonra akış kurtarmanın yanıtı tekrar istemesine ve birleştirmesine izin verin." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models yanıtlarına görüntüleme dostu ad alanlarını dahil edin. Yalnızca model kimliklerini kabul eden istemciler için bunu devre dışı bırakın." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Yetenekler korumalı alanında (skills sandbox) ağ erişimini etkinleştirin." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Hedef model gerekli yeteneklere (görüş, araçlar, yapılandırılmış çıktı, bağlam penceresi) sahip olmadığında, gönderimden önce istekleri reddedin. Kombinasyon katmanı uyumluluk filtresini atlayan doğrudan tek sağlayıcı isteklerini korur.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Sayfa bulunamadı", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index b517d5bf0b..6a22c6fa52 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Рекламуйте claude/<provider>/<model> mirror ids на /v1/models, щоб модель виявлення Claude Code gateway перераховувала не Claude моделі. Увага: подвоює записи каталогу для всіх клієнтів, коли увімкнено глобально.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Увімкніть адаптивні віртуальні смуги допуску для кожного тенанта під час надсилання провайдерам (#9654): сплеск навантаження одного тенанта більше не викликає 503 в іншого. Змінна середовища OMNIROUTE_CHAT_VIRTUAL_LANES має пріоритет над цим налаштуванням у панелі; зміни набувають чинності після перезапуску сервера.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "додому", "dashboard": "Приладова панель", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Дозволити відновленню потоку повторно запитувати відповідь і зшивати її після того, як байти вже дійшли до клієнта." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Включати зручні для відображення поля назв у відповіді /v1/models. Вимкніть це для клієнтів, які приймають лише ідентифікатори моделей." }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Увімкнути доступ до мережі в пісочниці навичок." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Відхиляйте запити перед відправкою, коли цільова модель не має необхідних можливостей (зір, інструменти, структурований вихід, контекстне вікно). Захищає прямі запити від одного постачальника, які обходять фільтр сумісності комбінаційного шару.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Сторінку не знайдено", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 6b244f374c..9f281b3c99 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models پر claude/<provider>/<model> آئینہ شناختوں کا اشتہار دیں تاکہ Claude Code گیٹ وے ماڈل کی دریافت غیر-Claude ماڈلز کی فہرست بنائے۔ انتباہ: جب عالمی طور پر فعال ہو تو تمام کلائنٹس کے لیے کیٹلاگ کی اندراجات دوگنا کرتا ہے۔", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "پرووائیڈر بھیجنے کے لیے فی ٹیننٹ انکولی ورچوئل ایڈمیشن لین فعال کریں (#9654): ایک ٹیننٹ کا اچانک بوجھ اب دوسرے ٹیننٹ کو 503 نہیں دیتا۔ OMNIROUTE_CHAT_VIRTUAL_LANES ماحولیاتی متغیر اس ڈیش بورڈ سیٹنگ پر فوقیت رکھتا ہے؛ تبدیلیاں سرور دوبارہ شروع ہونے پر اثر انداز ہوتی ہیں۔", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "اسٹریم ریکوری کو دوبارہ جواب کی درخواست کرنے اور بائٹس کے پہلے ہی کلائنٹ تک پہنچنے کے بعد اسے جوڑنے کی اجازت دیں۔" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models کے جوابات میں ڈسپلے کے لیے موزوں نام کے فیلڈز شامل کریں۔ ان کلائنٹس کے لیے اسے غیر فعال کریں جو صرف ماڈل IDs قبول کرتے ہیں۔" }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "اسکلز سینڈ باکس میں نیٹ ورک تک رسائی کو فعال کریں۔" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "جب ہدف ماڈل میں ضروری صلاحیتیں (نظریات، ٹولز، منظم آؤٹ پٹ، سیاق و سباق کی کھڑکی) نہیں ہوتیں تو بھیجنے سے پہلے درخواستوں کو مسترد کریں۔ یہ براہ راست واحد فراہم کنندہ کی درخواستوں کی حفاظت کرتا ہے جو کمبو-لیئر کی ہم آہنگی کے فلٹر کو نظر انداز کرتی ہیں۔", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "صفحہ نہیں ملا", diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 4e89d3abf3..8af8cd7c5d 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server boshqaruvidagi vositalar sikli", "description": "Model mijoz foydalanishi mumkin boʻlgan javobni qaytarmaguncha oqimsiz server boshqaruvidagi vosita chaqiruvlarini davom ettiring." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "Hamkorlik havolasi", "dismissAriaLabel": "Yopish" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "/v1/models katalogida fikrlash darajasi variantlarini (masalan, -low, -medium, -high) yaratishni o‘chirib qo‘ying." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "/v1/models katalogida fikrlash darajasi variantlarini (masalan, -low, -medium, -high) yaratishni o‘chirib qo‘ying.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 93033f589d..90b7c96e75 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -993,6 +993,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Quảng bá các id phản chiếu claude/<provider>/<model> trên /v1/models để tính năng khám phá mô hình qua gateway của Claude Code liệt kê được các mô hình không phải Claude. Cảnh báo: khi bật ở phạm vi toàn cục, số mục trong danh mục tăng gấp đôi với mọi client.", "featureFlagNoThinkingAliasEnabledDescription": "Công tắc chính cho các bí danh gateway no-think/<provider>/<model>. Bật (mặc định): /v1/models quảng bá biến thể không suy nghĩ cho mọi mô hình Claude có khả năng suy nghĩ đủ điều kiện, và id no-think/ được gửi trên một yêu cầu sẽ giải quyết lại về mô hình thực với phần lý luận bị triệt tiêu. Tắt: không có biến thể nào được quảng bá và id no-think/ được xử lý như bất kỳ id mô hình không xác định nào khác. Tùy chọn tham gia/từ chối ModelSpec.noThinkingAlias theo từng mô hình vẫn áp dụng khi tính năng này bật.", "featureFlagChatVirtualLanesEnabledDescription": "Bật làn tiếp nhận ảo thích ứng cho từng đối tượng thuê (tenant) để phân phối nhà cung cấp (#9654): một đợt bùng phát của tenant này không còn trả 503 cho tenant khác. Biến môi trường OMNIROUTE_CHAT_VIRTUAL_LANES được ưu tiên hơn cài đặt bảng điều khiển này; các thay đổi có hiệu lực khi khởi động lại máy chủ.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "Trang chủ", "dashboard": "Trang tổng quan", @@ -12985,6 +12986,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Cho phép khôi phục luồng bằng cách yêu cầu lại và ghép phản hồi sau khi dữ liệu đã bắt đầu được gửi tới ứng dụng khách." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "Giúp việc tiếp tục luồng giữa chừng an toàn với lệnh gọi công cụ: không bao giờ tiếp tục một luồng bị ngắt sau khi đã gửi lệnh gọi công cụ (đang xử lý hoặc đã hoàn tất), và dừng sau một lần tiếp tục rỗng thay vì dùng hết số lần thử lại." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Thêm trường tên dễ đọc vào phản hồi /v1/models. Tắt với các ứng dụng khách chỉ chấp nhận ID mô hình." }, @@ -13021,6 +13025,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Vòng lặp công cụ do máy chủ sở hữu", "description": "Tiếp tục các lời gọi công cụ do máy chủ sở hữu ở chế độ không streaming cho đến khi mô hình trả về phản hồi mà máy khách dùng được." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "Nguồn gốc của Retry-After", + "description": "Với các phản hồi không khả dụng 429/503 tổng hợp, bỏ Retry-After khi không biết thời điểm thử lại cụ thể thay vì gửi giá trị giả 1 giây, thêm error.retry_after_provenance và đọc gợi ý thử lại dạng văn bản trên các đường thoát của combo." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "Dừng ưu tiên được bảo vệ do hạ tầng trả 502", + "description": "Trả 502 thay vì 503 khi một mục tiêu ưu tiên chỉ dự phòng theo hạn mức dừng combo vì một nguyên nhân chắc chắn không phải hạn mức: bộ ngắt mạch của nhà cung cấp đang mở hoặc bỏ qua do dự đoán độ trễ." } } }, @@ -13634,6 +13646,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Từ chối yêu cầu trước khi gửi đi khi mô hình đích thiếu các khả năng bắt buộc (thị giác, công cụ, đầu ra có cấu trúc, cửa sổ ngữ cảnh). Bảo vệ các yêu cầu trực tiếp đến một nhà cung cấp khi chúng bỏ qua bộ lọc tương thích của combo.", "featureFlagDisableContextWindowChecksDescription": "Bỏ qua kiểm tra cục bộ của OmniRoute về cửa sổ ngữ cảnh và giới hạn token đầu vào tối đa cho yêu cầu trực tiếp đến một mô hình đơn lẻ. Nhà cung cấp thượng nguồn vẫn áp dụng các giới hạn thực tế. Tính năng nén prompt và giới hạn token đầu ra vẫn hoạt động.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "Không tìm thấy trang", diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index fea5978234..e72f3c43a3 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -13020,6 +13020,17 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Àyíká Irinṣẹ́ Tí Sáfà Ń Ṣàkóso", "description": "Tẹ̀síwájú pẹ̀lú àwọn ìpè irinṣẹ́ tí sáfà ń ṣàkóso tí kì í sanwọ́ títí àwòṣe yóò fi dá ìdáhùn tí oníbàárà lè lò padà." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." } } }, @@ -14143,5 +14154,7 @@ "partnerLinkNote": "Ọ̀nà asopọ aláṣiṣẹ́pọ̀", "dismissAriaLabel": "Pa á tì" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Mú ṣíṣẹ̀dá àwọn ẹ̀yà ìpele ìrònú (fún àpẹẹrẹ -low, -medium, -high) nínú àkójọ /v1/models ṣiṣẹ́ mọ́." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Mú ṣíṣẹ̀dá àwọn ẹ̀yà ìpele ìrònú (fún àpẹẹrẹ -low, -medium, -high) nínú àkójọ /v1/models ṣiṣẹ́ mọ́.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__: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.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__: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." } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index d5f97f5896..bffe8f36ef 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "在 /v1/models 上发布 claude/<provider>/<model> 镜像 ID,让 Claude Code 网关模型发现能列出非 Claude 模型。警告:全局启用会使所有客户端的目录条目翻倍。", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "为提供者调度启用按租户的自适应虚拟准入通道(#9654):一个租户的突发流量不再导致另一个租户收到 503。OMNIROUTE_CHAT_VIRTUAL_LANES 环境变量优先于此仪表板设置;更改在服务器重启后生效。", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "首页", "dashboard": "仪表板", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "允许流恢复在字节已到达客户端后重新请求响应并进行拼接。" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "在 /v1/models 响应中包含易于显示的名称字段。对于仅接受模型 ID 的客户端,请禁用此项。" }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "在技能沙箱中启用网络访问。" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "在目标模型缺少所需能力(视觉、工具、结构化输出、上下文窗口)时,拒绝调度前的请求。保护绕过组合层兼容性过滤器的直接单一提供者请求。", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "页面未找到", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 0a0b64d4b8..11691c6e64 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -992,6 +992,7 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "在 /v1/models 上廣告 claude/<provider>/<model> 鏡像 ID,以便 Claude Code 閘道模型發現列出非 Claude 模型。警告:當全域啟用時,會為所有客戶端重複目錄條目。", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "為提供者調度啟用按租戶的自適應虛擬准入通道(#9654):一個租戶的突發流量不再導致另一個租戶收到 503。OMNIROUTE_CHAT_VIRTUAL_LANES 環境變數優先於此儀表板設定;變更在伺服器重新啟動後生效。", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "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.", "sidebar": { "home": "首頁", "dashboard": "儀表板", @@ -12977,6 +12978,9 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "允許串流復原在位元組已到達用戶端後再次請求回應並將其拼接。" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "在 /v1/models 回應中包含顯示友善的名稱欄位。對於僅接受模型 ID 的用戶端請停用此項。" }, @@ -13009,6 +13013,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "在技能沙盒中啟用網路存取。" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13634,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "在目標模型缺乏所需功能(視覺、工具、結構化輸出、上下文窗口)時,拒絕發送前的請求。保護繞過組合層兼容性過濾器的直接單一提供者請求。", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "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.", "publicSystem": { "notFound": { "title": "找不到頁面", diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index 92a87b2738..3c16a03e8a 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -1,6 +1,6 @@ import { normalizeComboModels, type ComboStep } from "./steps"; import { resolveComboTargetModelStr } from "../../../open-sse/services/combo/opencodeTargetAlias.ts"; -import { resolveProviderAlias } from "../../../open-sse/services/model.ts"; +import { resolveProviderAlias } from "../../../open-sse/services/providerAlias.ts"; type JsonRecord = Record; diff --git a/src/lib/combos/deadConfigKeys.ts b/src/lib/combos/deadConfigKeys.ts new file mode 100644 index 0000000000..0ac6776255 --- /dev/null +++ b/src/lib/combos/deadConfigKeys.ts @@ -0,0 +1,28 @@ +/** + * Combo config keys that are dead at runtime: no reader in open-sse or + * src/lib, no default in comboConfig.ts, rejected history via migration 103. + * The 9 other v3.8.31-era keys (queueDepth, fallbackDelayMs, handoffProviders, + * maxComboDepth, manifestRouting, complexityAwareRouting, pipeline_enabled, + * shadowRouting, evalRouting) are still consumed at runtime — never add them here. + */ +export const DEAD_COMBO_CONFIG_KEYS: ReadonlyArray = Object.freeze([ + "pipelineConcurrency", + "resetAwareEnabled", + "resetAwareWindow", +]); + +export function stripDeadComboConfigKeys(rawConfig: T): T { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { + return rawConfig; + } + let mutated = false; + const next: Record = {}; + for (const [key, value] of Object.entries(rawConfig as Record)) { + if ((DEAD_COMBO_CONFIG_KEYS as ReadonlyArray).includes(key)) { + mutated = true; + continue; + } + next[key] = value; + } + return (mutated ? next : rawConfig) as T; +} diff --git a/src/lib/db/batches.ts b/src/lib/db/batches.ts index 9d3a8e4531..5282313ac2 100644 --- a/src/lib/db/batches.ts +++ b/src/lib/db/batches.ts @@ -436,11 +436,12 @@ export const INSTANCE_SWEEP_CHUNK = 200; * widening the sweep, and a scope carrying BOTH `apiKeyId` and `allTenants` is * rejected rather than widened. * - * Batches whose `api_key_id` IS NULL are intentionally OUT of a key-scoped sweep. - * This diverges from `scopeCheck` in `src/app/api/v1/batches/[id]/route.ts`, - * which lets any key read/delete a single unowned batch by id: a bulk destructive - * sweep must never reach records the key does not own, so unowned batches are - * only swept by `{ allTenants: true }`. + * Batches whose `api_key_id` IS NULL are intentionally OUT of a key-scoped sweep: + * a bulk destructive sweep must never reach records the key does not own, so + * unowned batches are only swept by `{ allTenants: true }`. The single-item routes + * apply the same rule through `canAccessOwnedRecord` in + * `src/app/api/v1/_helpers/apiKeyScope.ts` (a null owner is denied to every + * non-session caller — GHSA-2jm2-mpx8-6523). * * In key mode the file half is owner-scoped too: only files whose api_key_id is * the caller's are soft-deleted; a referenced file another tenant owns (or an diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts index 8498ce3331..35f7263919 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -1,4 +1,11 @@ import { getDbInstance } from "./core"; +import { ERROR_TYPE_CONTRACT } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { ERROR_TYPE_CONTRACT } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { + SEARCH_CREDENTIAL_FALLBACKS, + SEARCH_PROVIDERS, +} from "@omniroute/open-sse/config/searchRegistry.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; /** * Aggregation queries over `call_logs` extracted from route handlers. @@ -156,19 +163,84 @@ export function getProviderUsageSince(since: string): ProviderUsageRow[] { // /api/search/stats — search provider aggregates + recent entries // --------------------------------------------------------------------------- +function sqlStringLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +let searchLiveProviderGuardSql: string | null = null; + +/** Always applied: never surface a NULL provider or the '-' sentinel. */ +const SEARCH_PROVIDER_PRESENT_SQL = "c.provider IS NOT NULL AND c.provider != '-'"; + +/** + * WHERE fragment shared by every search query below (alias `c` = call_logs). + * A search row is surfaced only when its provider is still servable: + * - never a NULL provider or the '-' sentinel; + * - with SEARCH_STATS_HIDE_DELETED_CONNECTIONS on, a keyed provider also needs a + * provider_connections row, for itself or for one of its credential fallbacks + * (perplexity-search reuses a `perplexity` key), so a deleted connection stops + * resurfacing from its retained call_logs rows; + * - keyless providers (`authType: "none"` in the search registry, e.g. + * duckduckgo-free, searxng-search, anonymous context7) are always live — + * they are served without any provider_connections row. + * The flag defaults to off, which keeps the historical stats: every retained row + * with a real provider id counts. Built from registry constants on first use. + */ +function getSearchLiveProviderGuardSql(): string { + if (!isSearchStatsHideDeletedConnectionsEnabled()) return SEARCH_PROVIDER_PRESENT_SQL; + if (searchLiveProviderGuardSql !== null) return searchLiveProviderGuardSql; + const keyless = Object.values(SEARCH_PROVIDERS) + .filter((provider) => provider.authType === "none") + .map((provider) => sqlStringLiteral(provider.id)); + const fallbackPairs = Object.entries(SEARCH_CREDENTIAL_FALLBACKS).flatMap( + ([searchId, fallback]) => + (Array.isArray(fallback) ? fallback : [fallback]).map( + (fallbackId) => `(${sqlStringLiteral(searchId)}, ${sqlStringLiteral(fallbackId)})` + ) + ); + const keylessClause = keyless.length > 0 ? `c.provider IN (${keyless.join(", ")}) OR ` : ""; + const fallbackClause = + fallbackPairs.length > 0 + ? ` + OR EXISTS ( + SELECT 1 FROM (VALUES ${fallbackPairs.join(", ")}) fb + JOIN provider_connections pcf ON pcf.provider = fb.column2 + WHERE fb.column1 = c.provider + )` + : ""; + searchLiveProviderGuardSql = `${SEARCH_PROVIDER_PRESENT_SQL} + AND ( + ${keylessClause}EXISTS ( + SELECT 1 FROM provider_connections pc WHERE pc.provider = c.provider + )${fallbackClause} + )`; + return searchLiveProviderGuardSql; +} + +/** Fail closed to the historical behavior when the flag cannot be resolved. */ +function isSearchStatsHideDeletedConnectionsEnabled(): boolean { + try { + return isFeatureFlagEnabled("SEARCH_STATS_HIDE_DELETED_CONNECTIONS"); + } catch { + return false; + } +} + /** * Per-provider request count and average latency for search requests. + * Rows pass the search live-provider guard (see getSearchLiveProviderGuardSql). */ export function getSearchProviderStats(): SearchProviderStatRow[] { const db = getDbInstance(); return db .prepare( ` - SELECT provider, COUNT(*) as requests, - CAST(AVG(duration) AS INTEGER) as avg_latency_ms - FROM call_logs - WHERE request_type = 'search' - GROUP BY provider + SELECT c.provider, COUNT(*) as requests, + CAST(AVG(c.duration) AS INTEGER) as avg_latency_ms + FROM call_logs c + WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()} + GROUP BY c.provider ` ) .all() as SearchProviderStatRow[]; @@ -176,16 +248,18 @@ export function getSearchProviderStats(): SearchProviderStatRow[] { /** * Most recent 10 search entries (request_summary + provider + timestamp). + * Only rows from providers with a live connection are surfaced. */ export function getRecentSearchLogs(): SearchRecentRow[] { const db = getDbInstance(); return db .prepare( ` - SELECT request_summary, provider, timestamp - FROM call_logs - WHERE request_type = 'search' - ORDER BY timestamp DESC + SELECT c.request_summary, c.provider, c.timestamp + FROM call_logs c + WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()} + ORDER BY c.timestamp DESC LIMIT 10 ` ) @@ -199,6 +273,8 @@ export function getRecentSearchLogs(): SearchRecentRow[] { /** * Single-pass scalar aggregations for all search entries since `todayIso`. * `todayIso` is the ISO-8601 UTC start-of-day string used for the "today" count. + * Uses the same live-provider guard as the per-provider breakdown, so `total` + * always equals the sum of `getSearchProviderCounts()`. */ export function getSearchAggregateStats(todayIso: string): SearchAggregateStats { const db = getDbInstance(); @@ -206,12 +282,13 @@ export function getSearchAggregateStats(todayIso: string): SearchAggregateStats .prepare( `SELECT COUNT(*) as total, - COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today, - COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, - AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration, - COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached - FROM call_logs - WHERE request_type = 'search'` + COALESCE(SUM(CASE WHEN c.timestamp >= ? THEN 1 ELSE 0 END), 0) as today, + COALESCE(SUM(CASE WHEN c.status >= 400 OR c.error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, + AVG(CASE WHEN c.duration > 0 THEN c.duration END) as avg_duration, + COALESCE(SUM(CASE WHEN c.duration > 0 AND c.duration < 5 THEN 1 ELSE 0 END), 0) as cached + FROM call_logs c + WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()}` ) .get(todayIso) as SearchAggregateStats | undefined; return row ?? { total: 0, today: 0, errors: 0, avg_duration: null, cached: 0 }; @@ -219,14 +296,16 @@ export function getSearchAggregateStats(todayIso: string): SearchAggregateStats /** * Per-provider request count for search entries, ordered by count descending. + * Rows pass the search live-provider guard (see getSearchLiveProviderGuardSql). */ export function getSearchProviderCounts(): SearchProviderCountRow[] { const db = getDbInstance(); return db .prepare( - `SELECT provider, COUNT(*) as cnt - FROM call_logs WHERE request_type = 'search' - GROUP BY provider ORDER BY cnt DESC` + `SELECT c.provider, COUNT(*) as cnt + FROM call_logs c WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()} + GROUP BY c.provider ORDER BY cnt DESC` ) .all() as SearchProviderCountRow[]; } @@ -285,12 +364,30 @@ export function getFallbackStats( return row ?? { total: 0, with_requested: 0, fallback_eligible: 0, fallbacks: 0 }; } +// ERROR_TYPE_CUTOVER_ISO — single source of truth for the cutover: date of +// migration 158 (commit 4c15c05f9) that added `call_logs.error_type`. +export const ERROR_TYPE_CUTOVER_ISO = "2026-08-20"; + +// SQL `IN (...)` list of the persisted vocabulary. Built lazily (not at module +// evaluation) so an import cycle through the classifier can never observe the +// contract before it is initialised. Values are fixed identifiers; quotes are +// still escaped defensively. +let errorTypeVocabSql: string | null = null; +function getErrorTypeVocabSql(): string { + if (errorTypeVocabSql === null) { + errorTypeVocabSql = ERROR_TYPE_CONTRACT.map((v) => `'${v.replace(/'/g, "''")}'`).join(", "); + } + return errorTypeVocabSql; +} + /** * Failure-family breakdown over `call_logs` for the usage analytics endpoint. * Failures are rows with status >= 400 or a non-empty error summary; successes * are excluded in SQL. Rows predating migration 158 (`error_type` NULL, - * `timestamp` before 2026-08-20) land in `pre_migration`; other NULL families - * land in `unclassified`. + * `timestamp` before ERROR_TYPE_CUTOVER_ISO) land in `pre_migration`; other + * NULL families land in `unclassified`, and so does any stored value outside + * ERROR_TYPE_CONTRACT (free text written out of band). Every failure row lands + * in exactly one bucket, so the counts always sum to the failure total. * * @param whereClause - SQL WHERE clause (may be empty string) using the same * named params as the usage_history queries. @@ -305,9 +402,14 @@ export function getErrorTypeBreakdown( .prepare( ` SELECT - -- '2026-08-20' = commit 4c15c05f9 that added error_type (migration 158). - -- Lower bound, not exact: late upgraders have post-cutoff rows with NULL values. - CASE WHEN error_type IS NULL AND timestamp < '2026-08-20' THEN 'pre_migration' WHEN error_type IS NULL THEN 'unclassified' ELSE error_type END AS errorType, + -- ERROR_TYPE_CUTOVER_ISO (migration 158). Lower bound, not exact: late + -- upgraders have post-cutoff rows with NULL values. + CASE + WHEN error_type IS NULL AND timestamp < '${ERROR_TYPE_CUTOVER_ISO}' THEN 'pre_migration' + WHEN error_type IS NULL THEN 'unclassified' + WHEN error_type NOT IN (${getErrorTypeVocabSql()}) THEN 'unclassified' + ELSE error_type + END AS errorType, COUNT(*) AS count FROM call_logs ${whereClause} ${whereClause ? "AND" : "WHERE"} (status >= 400 OR error_summary IS NOT NULL) diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 7ccacdffbe..d58c60c55c 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -904,7 +904,10 @@ function createManagedDbBackup(db: SqliteDatabase, reason: string): boolean { ? parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS) : MAX_DB_BACKUPS; const retentionDays = process.env.DB_BACKUP_RETENTION_DAYS - ? parseNonNegativeInt(process.env.DB_BACKUP_RETENTION_DAYS, DEFAULT_DB_BACKUP_RETENTION_DAYS) + ? parseNonNegativeInt( + process.env.DB_BACKUP_RETENTION_DAYS, + DEFAULT_DB_BACKUP_RETENTION_DAYS + ) : DEFAULT_DB_BACKUP_RETENTION_DAYS; pruneBackupDirectory({ backupDir, maxFiles, retentionDays }); } catch { diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 016f0d3bb5..1b0e562476 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -29,6 +29,7 @@ import { } from "./proxies/mappers"; import { isGlobalProxyEnabled, PROXY_ALIVE_PREDICATE } from "./proxies/guards"; import { bumpProxyRegistryGeneration } from "./proxies/registryGeneration"; +import { isProxyRegistryStatus } from "@/shared/constants/proxyRegistryStatus"; export { hasBlockingProxyAssignment, hasBlockingProxyAssignmentForProvider, @@ -334,22 +335,48 @@ export async function createProxy(payload: ProxyPayload) { * * #7703: password is mutable and must not be part of the identity key. Including * it caused password-only credential rotations to create duplicate entries. + * + * On an existing row the status is written only when the payload carries a valid one, + * so a write that omits it never revives a proxy the operator or auto-disable turned off. + * + * `claimOwnership: false` is reserved for subscription sync: a matched row whose + * subscription_id differs from the payload's (manual rows included) is not written + * at all and the call returns `action: "skipped"`. The sync always sends its subscriptionId: + * a caller that omits it would find manual rows (null === null) counted as owned. */ -export async function upsertProxy(payload: ProxyPayload): Promise<{ - proxy: ProxyRegistryRecord | null; - action: "created" | "updated"; -}> { +export async function upsertProxy( + payload: ProxyPayload +): Promise<{ proxy: ProxyRegistryRecord | null; action: "created" | "updated" }>; +export async function upsertProxy( + payload: ProxyPayload, + options: { claimOwnership?: boolean } +): Promise<{ proxy: ProxyRegistryRecord | null; action: "created" | "updated" | "skipped" }>; +export async function upsertProxy( + payload: ProxyPayload, + options: { claimOwnership?: boolean } = {} +): Promise<{ proxy: ProxyRegistryRecord | null; action: "created" | "updated" | "skipped" }> { const db = getDbInstance(); const host = (payload.host || "").trim(); const port = Number(payload.port); const username = (payload.username || "").trim(); const existing = db - .prepare("SELECT id FROM proxy_registry WHERE host = ? AND port = ? AND username = ? LIMIT 1") - .get(host, port, username) as { id?: string } | undefined; + .prepare( + "SELECT id, subscription_id FROM proxy_registry WHERE host = ? AND port = ? AND username = ? LIMIT 1" + ) + .get(host, port, username) as { id?: string; subscription_id?: string | null } | undefined; if (existing?.id) { - const updated = await updateProxy(existing.id, payload); + const claimOwnership = options.claimOwnership ?? true; + const ownerChanged = (existing.subscription_id ?? null) !== (payload.subscriptionId ?? null); + if (!claimOwnership && ownerChanged) { + return { proxy: null, action: "skipped" }; + } + const { status, ...rest } = payload; + const changes: Partial = isProxyRegistryStatus(status) + ? { ...rest, status } + : rest; + const updated = await updateProxy(existing.id, changes); return { proxy: updated, action: "updated" }; } @@ -358,6 +385,8 @@ export async function upsertProxy(payload: ProxyPayload): Promise<{ } export async function updateProxy(id: string, payload: Partial) { + // No status filtering here: callers own the status they send. Writes that must + // preserve the stored status filter it in upsertProxy before calling this. const db = getDbInstance(); const existing = await getProxyById(id, { includeSecrets: true }); if (!existing) return null; diff --git a/src/lib/db/proxies/mappers.ts b/src/lib/db/proxies/mappers.ts index 6bcdb879c4..3903f76c2e 100644 --- a/src/lib/db/proxies/mappers.ts +++ b/src/lib/db/proxies/mappers.ts @@ -1,4 +1,5 @@ import { decrypt, looksEncrypted } from "../encryption"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import type { JsonRecord, ProxyScope, @@ -180,8 +181,8 @@ export function coerceProxyPayload(value: unknown, fallbackName: string): ProxyP type: parsed.protocol.replace(":", "") || "http", host: parsed.hostname, port: Number(parsed.port || (parsed.protocol === "https:" ? "443" : "8080")), - username: parsed.username ? decodeURIComponent(parsed.username) : "", - password: parsed.password ? decodeURIComponent(parsed.password) : "", + username: parsed.username ? decodeUserinfo(parsed.username) : "", + password: parsed.password ? decodeUserinfo(parsed.password) : "", status: "active", }; } catch { diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 8b4aa803af..bdc6e6603c 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -11,6 +11,7 @@ import { getProxyRegistryGeneration, resolveProxyForScopeFromRegistry } from "./ import { getComboModelProvider as getComboEntryProvider } from "@/lib/combos/steps"; import { requestBodyLimitMbFromEnv } from "@/shared/constants/bodySize"; import { DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE } from "@/shared/constants/responsesPreviousResponseId"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import { type JsonRecord, toRecord } from "./settings/shared"; import { resolveNoAuthSharedProviderProxy } from "./settings/noAuthProxyFallback"; @@ -411,8 +412,8 @@ function migrateProxyEntry(value: unknown): JsonRecord | null { port: url.port || (url.protocol === "socks5:" ? "1080" : url.protocol === "https:" ? "443" : "8080"), - username: url.username ? decodeURIComponent(url.username) : "", - password: url.password ? decodeURIComponent(url.password) : "", + username: url.username ? decodeUserinfo(url.username) : "", + password: url.password ? decodeUserinfo(url.password) : "", }; } catch { const parts = value.split(":"); diff --git a/src/lib/db/walMaintenance.ts b/src/lib/db/walMaintenance.ts index d884720a27..659500a19f 100644 --- a/src/lib/db/walMaintenance.ts +++ b/src/lib/db/walMaintenance.ts @@ -41,6 +41,9 @@ const DEFAULT_WAL_PASSIVE_INTERVAL_MS = 5 * 60 * 1000; const DEFAULT_WAL_GUARD_MAX_BYTES = 256 * 1024 * 1024; const RETRY_DELAY_MS = 60_000; +export const WAL_BUSY_NAMESPACE = "walMaintenance"; +export const WAL_BUSY_KEY = "busyTotal"; + let walTimer: NodeJS.Timeout | null = null; let walPassiveTimer: NodeJS.Timeout | null = null; let retryTimer: NodeJS.Timeout | null = null; @@ -49,13 +52,44 @@ let busyStreak = 0; let busyTotal = 0; let lastBusyAt: string | null = null; let lastOkAt: string | null = null; +// Busy events counted in memory but not yet added to the persisted counter. +let pendingBusyDelta = 0; +// The handle the running scheduler was started with; used for the shutdown flush. +let activeDb: SqliteAdapter | null = null; +/** + * Count one busy checkpoint. Memory only, on purpose: a busy checkpoint means the + * database is contended RIGHT NOW, and a write here would wait up to `busy_timeout` + * (2s) on the event loop. The increment is persisted later by flushBusyTotal() from + * a non-busy scheduler tick or at shutdown. + */ function recordBusy(): void { busyStreak++; busyTotal++; + pendingBusyDelta++; lastBusyAt = new Date().toISOString(); } +/** + * Add the pending busy events to the persisted counter. Best-effort and single-shot: + * any failure (locked, closed, missing table) keeps the delta pending for the next + * non-busy tick — there is no retry loop. The additive UPSERT stays correct when + * several processes share the database file. + */ +export function flushBusyTotal(db: SqliteAdapter | null): boolean { + if (pendingBusyDelta === 0 || !db || !db.open) return false; + try { + db.prepare( + "INSERT INTO key_value(namespace, key, value) VALUES(?, ?, ?) " + + "ON CONFLICT(namespace, key) DO UPDATE SET value = CAST(value AS INTEGER) + excluded.value" + ).run(WAL_BUSY_NAMESPACE, WAL_BUSY_KEY, pendingBusyDelta); + pendingBusyDelta = 0; + return true; + } catch { + return false; + } +} + function recordOk(): void { busyStreak = 0; lastOkAt = new Date().toISOString(); @@ -207,6 +241,7 @@ function schedulePassiveRetry(db: SqliteAdapter): void { logCheckpointOutcome(outcome, "PASSIVE", busyStreak); } else if (outcome.ok) { recordOk(); + flushBusyTotal(db); } else { logCheckpointOutcome(outcome, "PASSIVE", busyStreak); } @@ -239,6 +274,7 @@ function startWalPassiveScheduler( isBuildPhase: isNextBuildPhase(), }); if (stats.skipped) return; + if (!stats.busy && stats.ok) flushBusyTotal(db); if (stats.busy || (stats.checkpointedFrames ?? 0) > 0) { console.log( `[DB] WAL passive checkpoint (busy=${stats.busy ? 1 : 0} logFrames=${stats.logFrames} ` + @@ -273,8 +309,13 @@ export function startWalMaintenance( sqliteFile: string | null, env: NodeJS.ProcessEnv = process.env ): void { + // stopWalMaintenance() flushes what it can and zeroes session state, so capture the + // in-memory total first; the gate stays before any DB touch. + const priorBusyTotal = busyTotal; stopWalMaintenance(); if (sqliteFile === null || isCloud || isNextBuildPhase() || isAutomatedTestProcess()) return; + activeDb = db; + busyTotal = mergeBusyTotal(priorBusyTotal, loadPersistedBusyTotal(db)); const intervalMs = getWalMaintenanceIntervalMs(env); if (intervalMs <= 0) { startWalPassiveScheduler(db, sqliteFile, env); @@ -294,6 +335,7 @@ export function startWalMaintenance( schedulePassiveRetry(db); } else if (outcome.ok) { recordOk(); + flushBusyTotal(db); console.log( `[DB] Periodic SQLite WAL checkpoint completed (TRUNCATE) in ${Date.now() - startedAtMs}ms ` + `(walMbBefore=${formatWalMb(walBeforeBytes)} walMbAfter=${formatWalMb(getWalFileSizeBytes(sqliteFile))} ` + @@ -311,6 +353,10 @@ export function startWalMaintenance( } export function stopWalMaintenance(): void { + // Shutdown / restart: best-effort persist of busy events not flushed by a tick. + flushBusyTotal(activeDb); + activeDb = null; + pendingBusyDelta = 0; if (walTimer) { clearInterval(walTimer); walTimer = null; @@ -334,6 +380,27 @@ export function getWalMaintenanceState(): WalMaintenanceState { return { ticks, busyStreak, busyTotal, lastBusyAt, lastOkAt }; } +export function loadPersistedBusyTotal(db: SqliteAdapter): number { + try { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(WAL_BUSY_NAMESPACE, WAL_BUSY_KEY) as { value: unknown } | undefined; + const n = Number(row?.value); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; + } catch (error) { + // Boot read-path, not the hot scheduler path: never fail silently. + console.warn(`[DB] WAL busy counter unreadable, starting from 0: ${String(error)}`); + return 0; + } +} + +export function mergeBusyTotal(prior: number, loaded: number): number { + // Both inputs floored, non-finite or negative → 0 (matches load fallback). + const p = Number.isFinite(prior) && prior > 0 ? Math.floor(prior) : 0; + const l = Number.isFinite(loaded) && loaded > 0 ? Math.floor(loaded) : 0; + return Math.max(p, l); +} + export function __resetForTests(): void { stopWalMaintenance(); } diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts index 02c99f0e94..dd9b4d45e1 100644 --- a/src/lib/guardrails/visionBridgeHelpers.ts +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -309,6 +309,14 @@ async function fetchRemoteImageAsDataUri( fetchImpl: typeof fetch = VISION_BRIDGE_UA_FETCH ): Promise { const remoteImage = await fetchRemoteImage(imageUrl, { + // GHSA-34rg-3pqj-35g9: `imageUrl` is caller input (a chat `image_url` part) — pin + // `public-only` explicitly; never 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 inline the bytes into the vision self-call. + guard: "public-only", + // `pinDns` is validation-only here: with `fetchImpl` injected the library validates + // every DNS answer but cannot pin the connection (it never builds its own fetch). + pinDns: true, signal, // Bypass the runtime's hooked global fetch (ProxyFetch) — a dead local // proxy (e.g. 127.0.0.1:8317) would otherwise break the download. diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 6544ebd7e4..86bfb025e2 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -20,7 +20,7 @@ import { GROK_BUILD_TOKEN_URL, } from "@omniroute/open-sse/config/grokBuild.ts"; import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts"; -import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersion.ts"; +import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersionPin.ts"; import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab"; /** @@ -375,7 +375,7 @@ export const KIRO_CONFIG = { // Cursor stores credentials in SQLite database: state.vscdb // Keys: cursorAuth/accessToken, cursorAuth/refreshToken, storage.serviceMachineId // Deep-control PKCE + refresh aligned with OpenCodex (lidge-jun/opencodex src/oauth/cursor.ts). -// clientVersion pin lives in open-sse/utils/cursorAgentCliVersion.ts — single source of truth. +// clientVersion pin lives in open-sse/utils/cursorAgentCliVersionPin.ts — single source of truth. export const CURSOR_CONFIG = { // API endpoints apiEndpoint: "https://api2.cursor.sh", diff --git a/src/lib/proxyEgress.ts b/src/lib/proxyEgress.ts index af7ab5ded5..5a11ace55e 100644 --- a/src/lib/proxyEgress.ts +++ b/src/lib/proxyEgress.ts @@ -363,7 +363,8 @@ export interface ProxyValidationResult { egressIp: string | null; latencyMs: number; previousStatus: string | null; - newStatus: "active" | "error"; + newStatus: string; + preserved: boolean; } /** @@ -428,6 +429,25 @@ export async function validateProxyPool(deps?: { }); const probe = await resolveEgressIp(url, { force: true }); const alive = !!probe.ip && !probe.error; + // Operator/health statuses stay untouched by validation: the probe still + // ran above, so the report keeps its alive/egressIp signal. The two-value + // literal mirrors PROXY_ALIVE_PREDICATE in db/proxies/guards.ts; revisit if + // the status registry is ever derived from a single shared source (part 2). + const previous = (p.status ?? "").toLowerCase(); + if (previous === "inactive" || previous === "dead") { + report.push({ + proxyId: p.id, + host: p.host, + port: p.port, + alive, + egressIp: probe.ip, + latencyMs: probe.latencyMs, + previousStatus: p.status ?? null, + newStatus: p.status as string, + preserved: true, + }); + continue; + } const newStatus: "active" | "error" = alive ? "active" : "error"; await markStatus(p.id, newStatus, { latencyMs: probe.latencyMs, egressIp: probe.ip }); report.push({ @@ -439,6 +459,7 @@ export async function validateProxyPool(deps?: { latencyMs: probe.latencyMs, previousStatus: p.status ?? null, newStatus, + preserved: false, }); } diff --git a/src/lib/proxySubscription/parse.ts b/src/lib/proxySubscription/parse.ts index 13d9b545bc..122adc4748 100644 --- a/src/lib/proxySubscription/parse.ts +++ b/src/lib/proxySubscription/parse.ts @@ -15,6 +15,7 @@ * Source: operator-supplied subscription feature (Karing-style proxy). */ import * as yaml from "js-yaml"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; export type DirectProxyType = "http" | "https" | "socks5"; export type RawProxyProtocol = @@ -67,13 +68,7 @@ export interface ParsedSubscription { nodes: SubscriptionNode[]; needsCore: NeedsCoreNode[]; format: - | "clash-yaml" - | "clash-json" - | "v2ray-json" - | "lines" - | "base64-lines" - | "empty" - | "unknown"; + "clash-yaml" | "clash-json" | "v2ray-json" | "lines" | "base64-lines" | "empty" | "unknown"; } function looksLikeBase64(s: string): boolean { @@ -108,7 +103,9 @@ function asProtocol(raw: unknown): RawProxyProtocol { return "unknown"; } -function nodeFromClashObject(obj: Record): SubscriptionNode | NeedsCoreNode | null { +function nodeFromClashObject( + obj: Record +): SubscriptionNode | NeedsCoreNode | null { if (!obj || typeof obj !== "object") return null; const name = typeof obj.name === "string" ? obj.name : ""; const type = asProtocol(obj.type); @@ -187,8 +184,8 @@ function nodeFromUri(uri: string): SubscriptionNode | NeedsCoreNode | null { type: scheme as DirectProxyType, host, port, - username: parsed.username ? decodeURIComponent(parsed.username) : undefined, - password: parsed.password ? decodeURIComponent(parsed.password) : undefined, + username: parsed.username ? decodeUserinfo(parsed.username) : undefined, + password: parsed.password ? decodeUserinfo(parsed.password) : undefined, rawProtocol: scheme as RawProxyProtocol, }; } @@ -219,7 +216,10 @@ function nodeFromUri(uri: string): SubscriptionNode | NeedsCoreNode | null { return null; } -function collectFromArray(items: unknown[], format: ParsedSubscription["format"]): ParsedSubscription { +function collectFromArray( + items: unknown[], + format: ParsedSubscription["format"] +): ParsedSubscription { const nodes: SubscriptionNode[] = []; const needsCore: NeedsCoreNode[] = []; for (const item of items) { @@ -247,7 +247,10 @@ function parseClashYaml(content: string): ParsedSubscription { return collectFromArray(doc.proxies, "clash-yaml"); } if (doc && Array.isArray((doc as Record).outbounds)) { - return collectFromArray((doc as Record).outbounds as unknown[], "clash-yaml"); + return collectFromArray( + (doc as Record).outbounds as unknown[], + "clash-yaml" + ); } } catch { // fall through to unknown @@ -288,13 +291,17 @@ export function parseSubscription(body: string): ParsedSubscription { const json = JSON.parse(content); if (Array.isArray(json)) return collectFromArray(json, "v2ray-json"); if (json && Array.isArray(json.proxies)) return collectFromArray(json.proxies, "clash-json"); - if (json && Array.isArray(json.outbounds)) return collectFromArray(json.outbounds, "v2ray-json"); + if (json && Array.isArray(json.outbounds)) + return collectFromArray(json.outbounds, "v2ray-json"); } catch { // fall through } } - const lines = content.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const lines = content + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); if (lines.length > 0 && lines.some((l) => /^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\//.test(l))) { const res = parseLineList(lines); return base64Used ? { ...res, format: "base64-lines" } : res; @@ -304,9 +311,7 @@ export function parseSubscription(body: string): ParsedSubscription { } /** Redacted node summary for storage/display (no secrets). */ -export function redactedNodeSummary(parsed: ParsedSubscription): Array< - Record -> { +export function redactedNodeSummary(parsed: ParsedSubscription): Array> { const direct = parsed.nodes.map((n) => ({ name: n.name, type: n.type, diff --git a/src/lib/proxySubscription/subscriptionService.ts b/src/lib/proxySubscription/subscriptionService.ts index 60d0ce7158..b11a7fdad0 100644 --- a/src/lib/proxySubscription/subscriptionService.ts +++ b/src/lib/proxySubscription/subscriptionService.ts @@ -19,6 +19,7 @@ * protocol translation + node selection). Without it, those nodes are * reported but not routed. */ +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import { randomUUID } from "crypto"; import { getDbInstance } from "../db/core"; import { backupDbFile } from "../db/backup"; @@ -26,6 +27,7 @@ import { addProxiesToScopePool, bumpProxyRegistryGeneration, deleteProxyById, + updateProxy, upsertProxy, } from "../db/proxies"; import { bumpProxyConfigGeneration } from "../db/settings"; @@ -49,9 +51,7 @@ export type ProxySubscriptionStatus = "ok" | "error" | "empty"; * column (as JSON) so the dashboard can localize them via i18n instead of * showing server-side strings. */ export type ProxySubscriptionErrorCode = - | "LOCAL_CORE_ENDPOINT_INVALID" - | "NEEDS_CORE_NOT_CONFIGURED" - | "NO_USABLE_NODES"; + "LOCAL_CORE_ENDPOINT_INVALID" | "NEEDS_CORE_NOT_CONFIGURED" | "NO_USABLE_NODES"; /** Encode a user-facing error as `{ code, detail? }` for i18n on the client. */ export function subscriptionErrorCode(code: ProxySubscriptionErrorCode, detail?: string): string { @@ -204,14 +204,18 @@ export async function updateSubscription( const name = payload.name ?? existing.name; const url = payload.url ?? existing.url; const mode = payload.mode ?? existing.mode; - const ruleProviders = payload.ruleProviders !== undefined ? payload.ruleProviders : existing.ruleProviders; + const ruleProviders = + payload.ruleProviders !== undefined ? payload.ruleProviders : existing.ruleProviders; const localCoreEndpoint = - payload.localCoreEndpoint !== undefined ? payload.localCoreEndpoint : existing.localCoreEndpoint; + payload.localCoreEndpoint !== undefined + ? payload.localCoreEndpoint + : existing.localCoreEndpoint; const updateIntervalMinutes = payload.updateIntervalMinutes ?? existing.updateIntervalMinutes; const now = new Date().toISOString(); const enabledChanged = payload.enabled !== undefined && payload.enabled !== existing.enabled; - const enabled = payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : existing.enabled ? 1 : 0; + const enabled = + payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : existing.enabled ? 1 : 0; db.prepare( `UPDATE proxy_subscriptions @@ -254,7 +258,10 @@ export async function updateSubscription( return getSubscriptionById(id); } -export async function setSubscriptionEnabled(id: string, enabled: boolean): Promise { +export async function setSubscriptionEnabled( + id: string, + enabled: boolean +): Promise { return updateSubscription(id, { enabled }); } @@ -384,11 +391,36 @@ async function fetchSubscriptionContent(url: string): Promise { }); } +/** + * Keep a synced node only when this subscription owns its registry row. A row created + * by hand, or owned by another subscription, comes back as "skipped" and stays out of + * this pool. An owned row that pool validation flagged `error` is healed, since the feed + * just listed it again; `inactive` and `dead` are operator or health decisions and stay. + */ +async function keepOwnedSyncedRow( + upserted: Awaited>, + keptIds: string[] +): Promise { + if (upserted.action === "skipped" || !upserted.proxy?.id) return; + keptIds.push(upserted.proxy.id); + if (upserted.proxy.status === "error") { + await updateProxy(upserted.proxy.id, { status: "active" }); + } +} + /** Fetch + parse + sync nodes into proxy_registry, then (if enabled) (re)bind. */ async function syncSubscriptionUnsafe(id: string): Promise { const sub = await getSubscriptionById(id); if (!sub) { - return { subscriptionId: id, nodes: 0, needsCore: 0, boundProxies: 0, status: "error", error: "not found", applied: false }; + return { + subscriptionId: id, + nodes: 0, + needsCore: 0, + boundProxies: 0, + status: "error", + error: "not found", + applied: false, + }; } let body: string; @@ -397,8 +429,23 @@ async function syncSubscriptionUnsafe(id: string): Promise { } catch (e) { const msg = e instanceof Error ? e.message : String(e); const fetchConsec = (sub.consecutiveFailures || 0) + 1; - await updateSubscriptionStatus(id, "error", `Fetch failed: ${msg}`, null, new Date().toISOString(), fetchConsec); - return { subscriptionId: id, nodes: 0, needsCore: 0, boundProxies: 0, status: "error", error: msg, applied: false }; + await updateSubscriptionStatus( + id, + "error", + `Fetch failed: ${msg}`, + null, + new Date().toISOString(), + fetchConsec + ); + return { + subscriptionId: id, + nodes: 0, + needsCore: 0, + boundProxies: 0, + status: "error", + error: msg, + applied: false, + }; } const parsed: ParsedSubscription = parseSubscription(body); @@ -414,81 +461,108 @@ async function syncSubscriptionUnsafe(id: string): Promise { // better-sqlite3, so instead we guard against an unexpected DB error so a // half-completed sync can never be left flagged "ok". try { - // Directly-usable nodes → upsert into the registry as a pool. - for (const node of parsed.nodes) { - const upserted = await upsertProxy({ - name: node.name || `${sub.name} (${node.host}:${node.port})`, - type: node.type, - host: node.host, - port: node.port, - username: node.username, - password: node.password, - source: "subscription", - subscriptionId: id, - status: "active", - }); - if (upserted.proxy?.id) keptIds.push(upserted.proxy.id); - } - - // needsCore nodes → bind the operator-supplied local core endpoint (single). - if (parsed.needsCore.length > 0) { - if (sub.localCoreEndpoint && isLocalCoreEndpointAllowed(sub.localCoreEndpoint)) { - try { - const coreUrl = new URL(sub.localCoreEndpoint); - const coreType = coreUrl.protocol === "https:" ? "https" : coreUrl.protocol === "socks5:" ? "socks5" : "http"; - const upserted = await upsertProxy({ - name: `${sub.name} (local core)`, - type: coreType, - host: coreUrl.hostname, - port: Number(coreUrl.port) || (coreType === "https" ? 443 : 8080), - username: coreUrl.username ? decodeURIComponent(coreUrl.username) : undefined, - password: coreUrl.password ? decodeURIComponent(coreUrl.password) : undefined, + // Directly-usable nodes → upsert into the registry as a pool. + for (const node of parsed.nodes) { + // No status: a refresh must not revive a node the operator or auto-disable turned off. + const upserted = await upsertProxy( + { + name: node.name || `${sub.name} (${node.host}:${node.port})`, + type: node.type, + host: node.host, + port: node.port, + username: node.username, + password: node.password, source: "subscription", subscriptionId: id, - status: "active", - }); - if (upserted.proxy?.id) keptIds.push(upserted.proxy.id); - } catch { - warning = subscriptionErrorCode("LOCAL_CORE_ENDPOINT_INVALID"); + }, + { claimOwnership: false } + ); + await keepOwnedSyncedRow(upserted, keptIds); + } + + // needsCore nodes → bind the operator-supplied local core endpoint (single). + if (parsed.needsCore.length > 0) { + if (sub.localCoreEndpoint && isLocalCoreEndpointAllowed(sub.localCoreEndpoint)) { + try { + const coreUrl = new URL(sub.localCoreEndpoint); + const coreType = + coreUrl.protocol === "https:" + ? "https" + : coreUrl.protocol === "socks5:" + ? "socks5" + : "http"; + const upserted = await upsertProxy( + { + name: `${sub.name} (local core)`, + type: coreType, + host: coreUrl.hostname, + port: Number(coreUrl.port) || (coreType === "https" ? 443 : 8080), + username: coreUrl.username ? decodeUserinfo(coreUrl.username) : undefined, + password: coreUrl.password ? decodeUserinfo(coreUrl.password) : undefined, + source: "subscription", + subscriptionId: id, + }, + { claimOwnership: false } + ); + await keepOwnedSyncedRow(upserted, keptIds); + } catch { + warning = subscriptionErrorCode("LOCAL_CORE_ENDPOINT_INVALID"); + } + } else { + const nodes = parsed.needsCore + .map((n) => `${n.rawProtocol}://${n.host ?? ""}${n.port ? ":" + n.port : ""}`) + .join(", "); + warning = subscriptionErrorCode("NEEDS_CORE_NOT_CONFIGURED", nodes); + } + } + + // Remove stale subscription nodes no longer present in the fetched set. + if (keptIds.length > 0) { + const placeholders = keptIds.map(() => "?").join(","); + const stale = db + .prepare( + `SELECT id FROM proxy_registry WHERE subscription_id = ? AND id NOT IN (${placeholders})` + ) + .all(id, ...keptIds) as Array<{ id: string }>; + for (const r of stale) { + try { + await deleteProxyById(r.id, { force: true }); + } catch { + // ignore + } } } else { - const nodes = parsed.needsCore - .map((n) => `${n.rawProtocol}://${n.host ?? ""}${n.port ? ":" + n.port : ""}`) - .join(", "); - warning = subscriptionErrorCode("NEEDS_CORE_NOT_CONFIGURED", nodes); - } - } - - // Remove stale subscription nodes no longer present in the fetched set. - if (keptIds.length > 0) { - const placeholders = keptIds.map(() => "?").join(","); - const stale = db - .prepare(`SELECT id FROM proxy_registry WHERE subscription_id = ? AND id NOT IN (${placeholders})`) - .all(id, ...keptIds) as Array<{ id: string }>; - for (const r of stale) { - try { - await deleteProxyById(r.id, { force: true }); - } catch { - // ignore + const stale = db + .prepare("SELECT id FROM proxy_registry WHERE subscription_id = ?") + .all(id) as Array<{ id: string }>; + for (const r of stale) { + try { + await deleteProxyById(r.id, { force: true }); + } catch { + // ignore + } } } - } else { - const stale = db - .prepare("SELECT id FROM proxy_registry WHERE subscription_id = ?") - .all(id) as Array<{ id: string }>; - for (const r of stale) { - try { - await deleteProxyById(r.id, { force: true }); - } catch { - // ignore - } - } - } } catch (e) { const msg = e instanceof Error ? e.message : String(e); const writeConsec = (sub.consecutiveFailures || 0) + 1; - await updateSubscriptionStatus(id, "error", `Sync write failed: ${msg}`, null, new Date().toISOString(), writeConsec); - return { subscriptionId: id, nodes: 0, needsCore: 0, boundProxies: 0, status: "error", error: msg, applied: false }; + await updateSubscriptionStatus( + id, + "error", + `Sync write failed: ${msg}`, + null, + new Date().toISOString(), + writeConsec + ); + return { + subscriptionId: id, + nodes: 0, + needsCore: 0, + boundProxies: 0, + status: "error", + error: msg, + applied: false, + }; } const lastNodes = redactedNodeSummary(parsed); @@ -687,11 +761,15 @@ export function startSubscriptionScheduler(): void { try { await syncSubscription(s.id); } catch (e) { - console.warn(`[ProxySubscription] refresh failed for ${s.id}: ${e instanceof Error ? e.message : e}`); + console.warn( + `[ProxySubscription] refresh failed for ${s.id}: ${e instanceof Error ? e.message : e}` + ); } } } catch (e) { - console.warn(`[ProxySubscription] scheduler tick error: ${e instanceof Error ? e.message : e}`); + console.warn( + `[ProxySubscription] scheduler tick error: ${e instanceof Error ? e.message : e}` + ); } }; diff --git a/src/lib/quota/accountBuckets.ts b/src/lib/quota/accountBuckets.ts index 6b7ac1a14d..e12a286d43 100644 --- a/src/lib/quota/accountBuckets.ts +++ b/src/lib/quota/accountBuckets.ts @@ -16,6 +16,7 @@ * * Part of: Quota Sharing Engine — Phase 3 (#3 multi-window buckets). */ +import { boundedMap } from "./boundedMap"; // --------------------------------------------------------------------------- // Types @@ -62,8 +63,21 @@ export const SATURATION_THRESHOLD_PCT = 100; // In-process store // --------------------------------------------------------------------------- +/** + * Soft cap on stored buckets. Only SATURATED buckets are ever stored (a + * below-threshold observation deletes the entry), so evicting a live one would + * silently turn "saturated" into "eligible" — the fail-open this store exists to + * prevent. Over the cap only buckets whose reset instant already passed (stale + * saturation the next read would drop anyway) are evicted; live saturated + * buckets are never evicted and the store grows past the cap instead. 4096 ≈ + * 1000+ connections × their 5h/7d/7d: windows all saturated at once. + */ +export const ACCOUNT_BUCKETS_SOFT_CAP = 4096; + /** Key: `${connectionId}::${windowKey}`. */ -const _buckets = new Map(); +const _buckets = boundedMap("account-buckets", ACCOUNT_BUCKETS_SOFT_CAP, "lru", 0, { + shouldEvict: (entry, _key, nowMs) => entry.resetsAtMs > 0 && nowMs >= entry.resetsAtMs, +}); function storeKey(connectionId: string, windowKey: string): string { return `${connectionId}::${windowKey}`; @@ -104,7 +118,7 @@ export function isBucketSaturated( ): boolean { if (!connectionId || !windowKey) return false; // fail-open const key = storeKey(connectionId, windowKey); - const entry = _buckets.get(key); + const entry = _buckets.get(key, nowMs); if (!entry) return false; // fail-open // Lazy reset: the window rolled over → the saturation is stale. @@ -156,7 +170,7 @@ export function recordUsage( return; } - _buckets.set(key, { saturated: true, resetsAtMs }); + _buckets.set(key, { saturated: true, resetsAtMs }, nowMs); } /** diff --git a/src/lib/quota/boundedMap.ts b/src/lib/quota/boundedMap.ts new file mode 100644 index 0000000000..4c42be2e7a --- /dev/null +++ b/src/lib/quota/boundedMap.ts @@ -0,0 +1,179 @@ +// src/lib/quota/boundedMap.ts — size-capped Map for in-process routing/quota caches. +import { createLogger } from "@/shared/utils/logger"; + +/** + * - `lru`: no expiry; over the cap the least-recently-USED entry goes first + * (`get` refreshes recency). + * - `ttl`: entries expire `ttlMs` after they were last `set` (expired reads return + * undefined and drop the entry); over the cap expired entries are swept first, + * then the oldest-WRITTEN entry goes (reads do not refresh anything). + */ +export type BoundedMapPolicy = "lru" | "ttl"; + +export interface BoundedMapLogger { + warn(meta: Record, message: string): void; +} + +export interface BoundedMapOptions { + /** + * Return false to protect an entry from eviction. Protected entries are NEVER + * evicted: when every remaining entry is protected the map grows past its cap + * (and says so in the log) rather than dropping state whose loss would change + * routing — e.g. a saturated quota bucket (fail-open) or a semantic quality pin. + */ + shouldEvict?: (value: V, key: string, nowMs: number) => boolean; + /** Defaults to the project logger (`quota:bounded-map`). */ + log?: BoundedMapLogger; + /** Minimum gap between two eviction log lines for one map. Default 60s. */ + logIntervalMs?: number; +} + +interface Entry { + value: V; + ts: number; +} + +export interface BoundedMap { + get(key: string, nowMs?: number): V | undefined; + set(key: string, value: V, nowMs?: number): void; + delete(key: string): boolean; + clear(): void; + readonly size: number; + keys(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, V]>; + /** Lifetime counters, for tests and diagnostics. */ + stats(): { evictions: number; overflowInserts: number }; +} + +const DEFAULT_LOG_INTERVAL_MS = 60_000; + +let defaultLogger: BoundedMapLogger | null = null; +function getDefaultLogger(): BoundedMapLogger { + defaultLogger ??= createLogger("quota:bounded-map"); + return defaultLogger; +} + +export function boundedMap( + name: string, + limit: number, + policy: BoundedMapPolicy, + ttlMs = 0, + options: BoundedMapOptions = {} +): BoundedMap { + const inner = new Map>(); + const shouldEvict = options.shouldEvict ?? (() => true); + const logIntervalMs = options.logIntervalMs ?? DEFAULT_LOG_INTERVAL_MS; + const expires = policy === "ttl" && ttlMs > 0; + + let evictions = 0; + let overflowInserts = 0; + // Aggregated logging: the first event logs at once, later ones are summed and + // reported at most once per logIntervalMs — a hot cache at its cap must not + // produce one log line per request. + let pendingEvictions = 0; + let pendingOverflows = 0; + let lastLogAt = Number.NEGATIVE_INFINITY; + + function maybeLog(nowMs: number): void { + if (pendingEvictions === 0 && pendingOverflows === 0) return; + if (nowMs - lastLogAt < logIntervalMs) return; + lastLogAt = nowMs; + (options.log ?? getDefaultLogger()).warn( + { + map: name, + cap: limit, + size: inner.size, + evicted: pendingEvictions, + overflowInserts: pendingOverflows, + }, + `[boundedMap:${name}] cap ${limit} reached: evicted ${pendingEvictions} entr${pendingEvictions === 1 ? "y" : "ies"}` + + (pendingOverflows > 0 + ? `, grew past the cap ${pendingOverflows}x (all entries protected)` + : "") + ); + pendingEvictions = 0; + pendingOverflows = 0; + } + + function isExpired(entry: Entry, nowMs: number): boolean { + return expires && nowMs - entry.ts > ttlMs; + } + + function sweepExpired(nowMs: number): void { + for (const [k, e] of inner) { + if (isExpired(e, nowMs)) inner.delete(k); + } + } + + /** Map iteration order is recency (lru) or write order (ttl): the first evictable key wins. */ + function findVictim(nowMs: number): string | undefined { + for (const [k, e] of inner) { + if (shouldEvict(e.value, k, nowMs)) return k; + } + return undefined; + } + + function makeRoom(nowMs: number): void { + if (inner.size < limit) return; + if (expires) sweepExpired(nowMs); + while (inner.size >= limit) { + const victim = findVictim(nowMs); + if (victim === undefined) { + overflowInserts += 1; + pendingOverflows += 1; + break; + } + inner.delete(victim); + evictions += 1; + pendingEvictions += 1; + } + maybeLog(nowMs); + } + + return { + get(key: string, nowMs: number = Date.now()): V | undefined { + const entry = inner.get(key); + if (!entry) return undefined; + if (isExpired(entry, nowMs)) { + inner.delete(key); + return undefined; + } + if (policy === "lru") { + inner.delete(key); + inner.set(key, entry); + } + return entry.value; + }, + set(key: string, value: V, nowMs: number = Date.now()): void { + if (inner.has(key)) inner.delete(key); + else makeRoom(nowMs); + inner.set(key, { value, ts: nowMs }); + }, + delete(key: string): boolean { + return inner.delete(key); + }, + clear(): void { + inner.clear(); + }, + get size(): number { + return inner.size; + }, + keys(): IterableIterator { + return inner.keys(); + }, + [Symbol.iterator](): IterableIterator<[string, V]> { + const nowMs = Date.now(); + const it = inner.entries(); + function* gen(): Generator<[string, V]> { + for (const [k, e] of it) { + if (isExpired(e, nowMs)) continue; + yield [k, e.value]; + } + } + return gen(); + }, + stats() { + return { evictions, overflowInserts }; + }, + }; +} diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts index eb0365445a..b9986f5a62 100644 --- a/src/lib/quota/saturationSignals.ts +++ b/src/lib/quota/saturationSignals.ts @@ -24,6 +24,7 @@ */ import { createLogger } from "@/shared/utils/logger"; +import { boundedMap } from "./boundedMap"; import { updateAccountBuckets, type ClaudeUsageResult } from "./accountBuckets"; import type { QuotaUnit, QuotaWindow } from "./dimensions"; @@ -33,23 +34,20 @@ const log = createLogger("quota:saturation"); // Types // --------------------------------------------------------------------------- -interface CacheEntry { - value: number; // 0..1 - ts: number; // epoch ms -} - interface DimensionSpec { unit: QuotaUnit; window: QuotaWindow; } // --------------------------------------------------------------------------- -// In-memory cache (Map) +// In-memory cache (boundedMap, 30s TTL). Caps are +// generous (one entry per connection/provider/dimension or provider/connection): +// an evicted entry only costs one extra read, and normal deployments never hit them. // --------------------------------------------------------------------------- const CACHE_TTL_MS = 30_000; // 30 seconds -const _cache = new Map(); +const _cache = boundedMap("saturation-cache", 4096, "ttl", CACHE_TTL_MS); // Pending miss fetches, keyed like _cache. Concurrent getSaturation calls for // the same key share the promise instead of firing one upstream read each. @@ -79,9 +77,19 @@ interface TokenHeaderEntry { ts: number; } -const _rateLimitHeaders = new Map(); -const _tokenHeaders = new Map(); const RL_HEADER_TTL_MS = 5 * 60 * 1000; // 5 minutes +const _rateLimitHeaders = boundedMap( + "saturation-rl-headers", + 4096, + "ttl", + RL_HEADER_TTL_MS +); +const _tokenHeaders = boundedMap( + "saturation-token-headers", + 4096, + "ttl", + RL_HEADER_TTL_MS +); /** Test-only: clear the rate-limit + token header caches between asserts. */ export function _clearRateLimitHeaders(): void { @@ -249,7 +257,7 @@ export function getTokenHeaderSaturation( connectionId: string ): { saturation: number; resetAt: number | null } | null { const entry = _tokenHeaders.get(`${provider}:${connectionId}`); - if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return null; + if (!entry) return null; if (!(entry.limit > 0)) return null; const used = entry.limit - entry.remaining; const saturation = Math.min(1, Math.max(0, used / entry.limit)); @@ -342,7 +350,7 @@ async function fetchBailianSaturation(connectionId: string, dim: DimensionSpec): */ function anthropicHeaderSaturation(connectionId: string): number { const entry = _rateLimitHeaders.get(`anthropic:${connectionId}`); - if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return 0; + if (!entry) return 0; const used = entry.limit - entry.remaining; return Math.min(1, Math.max(0, used / entry.limit)); @@ -538,8 +546,8 @@ export async function getSaturation( ): Promise { const key = cacheKey(connectionId, provider, dim); const cached = _cache.get(key); - if (cached && Date.now() - cached.ts < CACHE_TTL_MS) { - return cached.value; + if (cached !== undefined) { + return cached; } const pending = _inflight.get(key); @@ -569,7 +577,7 @@ export async function getSaturation( ); value = 0; } - _cache.set(key, { value, ts: Date.now() }); + _cache.set(key, value); return value; })(); _inflight.set(key, task); diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 7a2181b0dd..693305e303 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -50,6 +50,7 @@ import { protectPipelinePayloads, buildRequestSummary, classifyCallLogError, + toStoredErrorType, } from "./callLogs/format"; import { clearArtifactReference, @@ -507,7 +508,9 @@ async function saveCallLogOperation(entry: any): Promise { // while reasoning source/char-count are recorded separately for observability. const tokensReasoning = getReasoningTokensOrNull(entry.tokens); const reasoningObservation = resolveReasoningObservation(tokensReasoning, entry.responseBody); - const errorType = classifyCallLogError(entry.status, entry.error, entry.provider); + const errorType = toStoredErrorType( + classifyCallLogError(entry.status, entry.error, entry.provider) + ); const logEntry = { id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(), timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(), diff --git a/src/lib/usage/callLogs/format.ts b/src/lib/usage/callLogs/format.ts index 63068e73bc..0f093474dd 100644 --- a/src/lib/usage/callLogs/format.ts +++ b/src/lib/usage/callLogs/format.ts @@ -1,5 +1,10 @@ +import { z } from "zod"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; -import { classifyProviderError } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { + classifyProviderError, + type ErrorTypeContract, + ERROR_TYPE_CONTRACT, +} from "@omniroute/open-sse/services/errorClassifier.ts"; import { sanitizeErrorMessage, sanitizeUpstreamDetails, @@ -166,9 +171,14 @@ export function buildRequestSummary( // #10670: per-call error family at the single write point. Reuses the // production classifier (chatCore.ts:3974, auth.ts:2598) so the persisted -// vocabulary is exactly PROVIDER_ERROR_TYPES. Successes (status < 400 with no -// error text) short-circuit to null — the classifier never returns a family -// for them anyway, this only skips the call. +// vocabulary is ERROR_TYPE_CONTRACT (PROVIDER_ERROR_TYPES + "unknown"). +// - Successes (0 < status < 400) are null. The classifier never returns a +// family below 400, so this only skips the call. +// - status 0 (no upstream response) is null without error text, otherwise a +// failure. +// - A failure the classifier cannot place is persisted as the explicit +// "unknown" (#13281) instead of NULL, so a NULL error_type keeps meaning +// "legacy row / not a failure" and the analytics breakdown can tell them apart. // Normalization: strings pass through, Error objects yield .message, any other // object yields "" (no caller passes plain objects — verified: 35 callers use // strings and Error only). Deliberate deviation from design §4 ("objet → @@ -177,8 +187,36 @@ export function classifyCallLogError( status: number, error: unknown, provider?: string | null -): string | null { +): ErrorTypeContract | null { const errorText = typeof error === "string" ? error : error instanceof Error ? error.message : ""; - if (status < 400 && errorText.length === 0) return null; - return classifyProviderError(status, errorText, provider); + if (status === 0 ? errorText.length === 0 : status < 400) return null; + return classifyProviderError(status, errorText, provider) ?? "unknown"; +} + +// #13441: defense in depth at the `call_logs.error_type` write boundary. The +// classifier is typed to the contract, but its runtime values come from +// PROVIDER_ERROR_TYPES while the contract is a frozen snapshot — a family added +// to one and not the other (or any future caller handing in its own string) +// would otherwise persist free text. Built once, on first use, so an import +// cycle through the classifier cannot observe the contract uninitialised. +let storedErrorTypeSchema: z.ZodEnum> | null = null; + +function getStoredErrorTypeSchema() { + if (storedErrorTypeSchema === null) { + storedErrorTypeSchema = z.enum( + ERROR_TYPE_CONTRACT as readonly [ErrorTypeContract, ...ErrorTypeContract[]] + ); + } + return storedErrorTypeSchema; +} + +/** + * Value persisted in `call_logs.error_type`. `null`/`undefined` (not a failure) + * stay NULL; a contract value passes through; anything else is stored as + * `unknown` — never thrown, so a log line is never lost. + */ +export function toStoredErrorType(value: unknown): ErrorTypeContract | null { + if (value === null || value === undefined) return null; + const parsed = getStoredErrorTypeSchema().safeParse(value); + return parsed.success ? parsed.data : "unknown"; } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index b45055dddf..340f302910 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -39,6 +39,27 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/forge-settings", // spawns via getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263) "/api/cli-tools/jcode-settings", // spawns via getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263) "/api/cli-tools/qwen-settings", // GET probes the local `qwen` binary; writes target ~/.qwen config files (Hard Rules #15 + #17) + // GHSA-35fw-cv32-2373: the 14 cli-tools routes below reach the SAME spawn as their six + // gated siblings above — getCliRuntimeStatus() -> locateCommand() -> runProcess("sh", -c + // 'command -v -- "$1"') -> spawn() — but sat on Tier 3 MANAGEMENT only, which + // requireManagementAuth() waives under requireLogin=false (incl. the fresh-install window). + // Exact entries on purpose: a blanket "/api/cli-tools/" prefix would also lock the + // non-spawning apply/backups/config/guide-settings/hermes-agent-settings/keys/logs/ + // openclaw/auto-order routes that tunnel-served dashboards legitimately use. + "/api/cli-tools/all-statuses", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/claude-settings", // spawns via getCliRuntimeStatus() to detect the `claude` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/cline-settings", // spawns via getCliRuntimeStatus() to detect the `cline` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/codewhale-settings", // spawns via getCliRuntimeStatus() to detect the `codewhale` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/codex-settings", // spawns via getCliRuntimeStatus() to detect the `codex` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/crush-settings", // spawns via getCliRuntimeStatus() to detect the `crush` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/deepseek-tui-settings", // spawns via getCliRuntimeStatus() to detect the `deepseek-tui` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/detect", // GET calls detectAllTools() -> execFile(binary, --version) + execFile("which") per tool (src/lib/cli-helper/tool-detector.ts) (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/droid-settings", // spawns via getCliRuntimeStatus() to detect the `droid` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/kilo-settings", // spawns via getCliRuntimeStatus() to detect the `kilo` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/openclaw-settings", // spawns via getCliRuntimeStatus() to detect the `openclaw` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373). Does NOT cover the non-spawning sibling /api/cli-tools/openclaw/auto-order (different segment). + "/api/cli-tools/pi-settings", // spawns via getCliRuntimeStatus() to detect the `pi` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/smelt-settings", // spawns via getCliRuntimeStatus() to detect the `smelt` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/status", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) "/api/services/", // T-10: embedded service lifecycle (spawn child processes) "/api/tunnels/cloudflared", // POST installs/starts/stops cloudflared; safe methods are exempted below "/api/tunnels/tailscale/disable", // stops Funnel and may stop tailscaled/Tailscale service @@ -66,6 +87,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/oauth/cursor/auto-import", // spawns execFile("which", argv-array-of-one-arg "cursor") to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one. "/api/oauth/kiro/auto-import", // reads host-local Kiro credential files (homedir kiro-cli data) — must reach the loopback-only gate, not the PUBLIC /api/oauth/ prefix (GHSA-wgwc-crjm-pmwv, GHSA-gxv4-955v-v6cm). Excluded from PUBLIC in publicApiRoutes.ts. "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). + "/api/skills/install", // POST stores the request's handlerCode verbatim as the skill handler with no allowlist; a value equal to the built-in `execute_command` / `eval_code` name aliases the real sandboxed built-in (src/lib/skills/executor.ts -> builtins.ts -> sandbox.ts childProcess.spawn). Transitive spawn the 6A.8 source-scan cannot see. Same class as /api/acp/agents (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) + "/api/skills/executions", // POST runs skillExecutor.execute() on any global/system skill with caller-chosen input — reaches the container spawn in src/lib/skills/sandbox.ts; only isAuthenticated()-gated, which requireLogin=false waives (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89). Registry list/delete, marketplace and skillssh stay remote-reachable. "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj). "/api/acp/agents", // ACP custom-agent registry: POST registers a client-chosen `binary`; GET / POST {action:"refresh"} runs detectInstalledAgents() -> execFileSync(probe.command, probe.args, { shell }) transitively (src/lib/acp/registry.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17, #7948) diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 8271d21c5b..dbb87ac26a 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -449,6 +449,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "danger", }, + { + key: "STREAM_RECOVERY_TOOLCALL_ORDER_FIX", + label: "Tool-Call-Safe Continuation", + description: + "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.", + descriptionI18nKey: "featureFlagStreamRecoveryToolcallOrderFixDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, { key: "MODEL_CATALOG_INCLUDE_NAMES", label: "Model Catalog Names", @@ -570,6 +582,54 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "SEARCH_STATS_HIDE_DELETED_CONNECTIONS", + label: "Hide Deleted Search Connections", + description: + "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.", + descriptionI18nKey: "featureFlagSearchStatsHideDeletedConnectionsDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, + { + key: "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER", + label: "Strict Free Badge", + description: + "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.", + descriptionI18nKey: "featureFlagFreeBadgeRequiresProviderFreeTierDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, + { + key: "RETRY_AFTER_PROVENANCE_ENABLED", + label: "Retry-After Provenance", + description: + "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.", + descriptionI18nKey: "featureFlagRetryAfterProvenanceEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "PROTECTED_PRIORITY_INFRA_502_ENABLED", + label: "Protected-Priority Infra Stops as 502", + description: + "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.", + descriptionI18nKey: "featureFlagProtectedPriorityInfra502EnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, // ──────────────── CLI (5) ──────────────── { diff --git a/src/shared/constants/proxyRegistryStatus.ts b/src/shared/constants/proxyRegistryStatus.ts new file mode 100644 index 0000000000..55841ccacf --- /dev/null +++ b/src/shared/constants/proxyRegistryStatus.ts @@ -0,0 +1,13 @@ +/** + * Statuses a caller may write on a proxy registry row. `error` is deliberately not + * listed: only pool validation sets it, and no import or update may send it. + */ +export const PROXY_REGISTRY_STATUS_VALUES = ["active", "inactive", "dead"] as const; + +export type ProxyRegistryStatus = (typeof PROXY_REGISTRY_STATUS_VALUES)[number]; + +export function isProxyRegistryStatus(value: unknown): value is ProxyRegistryStatus { + return ( + typeof value === "string" && (PROXY_REGISTRY_STATUS_VALUES as readonly string[]).includes(value) + ); +} diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index d6392b202b..1c9261347f 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -26,6 +26,23 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", "/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files + // GHSA-35fw-cv32-2373: 14 cli-tools routes that reach the same getCliRuntimeStatus() / + // detectAllTools() spawn as their gated siblings — must never be whitelistable via + // manage-scope bypass (Hard Rules #15 + #17). Exact entries; NOT a "/api/cli-tools/" blanket. + "/api/cli-tools/all-statuses", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry + "/api/cli-tools/claude-settings", // GET probes the `claude` binary via getCliRuntimeStatus() + "/api/cli-tools/cline-settings", // GET probes the `cline` binary via getCliRuntimeStatus() + "/api/cli-tools/codewhale-settings", // GET probes the `codewhale` binary via getCliRuntimeStatus() + "/api/cli-tools/codex-settings", // GET probes the `codex` binary via getCliRuntimeStatus() + "/api/cli-tools/crush-settings", // GET probes the `crush` binary via getCliRuntimeStatus() + "/api/cli-tools/deepseek-tui-settings", // GET probes the `deepseek-tui` binary via getCliRuntimeStatus() + "/api/cli-tools/detect", // GET calls detectAllTools() -> execFile(binary, --version) + execFile("which") per tool + "/api/cli-tools/droid-settings", // GET probes the `droid` binary via getCliRuntimeStatus() + "/api/cli-tools/kilo-settings", // GET probes the `kilo` binary via getCliRuntimeStatus() + "/api/cli-tools/openclaw-settings", // GET probes the `openclaw` binary via getCliRuntimeStatus() + "/api/cli-tools/pi-settings", // GET probes the `pi` binary via getCliRuntimeStatus() + "/api/cli-tools/smelt-settings", // GET probes the `smelt` binary via getCliRuntimeStatus() + "/api/cli-tools/status", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry "/api/services/", // T-10: can run npm install + spawn node processes "/api/tunnels/cloudflared", // POST installs/starts/stops cloudflared; safe methods remain read-only exempt "/api/tunnels/tailscale/disable", // stops Funnel and may stop tailscaled/Tailscale service @@ -40,6 +57,8 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review) + "/api/skills/install", // POST registers a handler string that can alias the built-in execute_command / eval_code (src/lib/skills/executor.ts -> builtins.ts -> sandbox.ts childProcess.spawn) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) + "/api/skills/executions", // POST runs skillExecutor.execute() -> container spawn in src/lib/skills/sandbox.ts — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17) "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) "/api/vnc-session", // #7892: spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) diff --git a/src/shared/schemas/playground.ts b/src/shared/schemas/playground.ts index 228ef1cebf..8c881b6c38 100644 --- a/src/shared/schemas/playground.ts +++ b/src/shared/schemas/playground.ts @@ -1,5 +1,6 @@ // src/shared/schemas/playground.ts import { z } from "zod"; +import { partialWithoutDefaults } from "@/shared/validation/partialWithoutDefaults"; /** Row da tabela playground_presets. */ export const PlaygroundPresetRowSchema = z.object({ @@ -22,7 +23,7 @@ export const PlaygroundPresetCreateSchema = z.object({ }); /** Body de PUT /api/playground/presets/[id]. */ -export const PlaygroundPresetUpdateSchema = PlaygroundPresetCreateSchema.partial(); +export const PlaygroundPresetUpdateSchema = partialWithoutDefaults(PlaygroundPresetCreateSchema); export const PlaygroundPresetListItemSchema = z.object({ id: z.string().uuid(), diff --git a/src/shared/utils/decodeUserinfo.ts b/src/shared/utils/decodeUserinfo.ts new file mode 100644 index 0000000000..418ae0482f --- /dev/null +++ b/src/shared/utils/decodeUserinfo.ts @@ -0,0 +1,15 @@ +/** + * decodeUserinfo — guarded percent-decoding for proxy URL userinfo segments. + * + * Correctly encoded values decode as before ("user%40name" -> "user@name"). + * A literal "%" ("user%name") makes decodeURIComponent throw URIError; + * fall back to the raw value instead of rejecting — the raw value may be + * the correct credential. + */ +export function decodeUserinfo(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/src/shared/utils/freeModels.ts b/src/shared/utils/freeModels.ts index 279e8cc295..328973013b 100644 --- a/src/shared/utils/freeModels.ts +++ b/src/shared/utils/freeModels.ts @@ -1,5 +1,5 @@ import { FREE_MODEL_BUDGETS, grantsFreeAccess } from "@omniroute/open-sse/config/freeModelCatalog"; -import { resolveProviderId } from "@/shared/constants/providers"; +import { getProviderById, resolveProviderId } from "@/shared/constants/providers"; import { globToRegex } from "@/shared/utils/globPattern"; import { AI_MODELS } from "@/shared/constants/models"; @@ -114,6 +114,53 @@ export function isFreeForProvider(provider: string, model: FreeModelCandidate): return providerHasFreeModels(provider) && isFreeModel(provider, model); } +/** Model row fields the provider-page "Free" badge looks at. */ +export interface FreeBadgeCandidate { + id: string; + name?: string | null; + free?: unknown; + isFree?: unknown; +} + +/** Feature flag that turns on the stricter badge rule (default off). */ +export const FREE_BADGE_STRICT_FLAG = "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER"; + +/** + * Whether the provider-page model list shows the "Free" badge for a model row. + * + * Default (`strict: false`) is the historical rule, unchanged: any truthy `free` field, + * a `:free` id suffix, "free"/"grátis" in the display name, or `isFreeModel`. + * + * With `strict: true` (feature flag FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER) only badges + * that cannot be right are removed: + * - the display-name heuristic ("Free" in a name is not a pricing signal); + * - a truthy-but-not-`true` `free` field (e.g. `free: "false"`); + * - a `:free` suffix on a REGISTERED provider without a documented free tier — the + * suffix is an OpenRouter convention that such a provider does not implement. + * Kept: catalogued free models, explicit `free`/`isFree === true`, and `:free` on + * free-tier providers (OpenRouter…) and on compatible/custom nodes, whose upstream may + * well be OpenRouter-compatible and honor the suffix. + */ +export function isModelFreeBadge( + provider: string, + model: FreeBadgeCandidate, + options: { strict?: boolean } = {} +): boolean { + if (!options.strict) { + return ( + Boolean(model.free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") || + isFreeModel(provider, { id: model.id, isFree: model.isFree as boolean | undefined }) + ); + } + const explicit = model.isFree === true || model.free === true; + if (explicit || isFreeModel(provider, { id: model.id })) return true; + if (!model.id.endsWith(":free")) return false; + const registered = getProviderById(resolveProviderId(provider)) != null; + return !registered || providerHasFreeModels(provider); +} + export interface SelectModelsForImportResult { models: T[]; /** diff --git a/src/shared/validation/partialWithoutDefaults.ts b/src/shared/validation/partialWithoutDefaults.ts new file mode 100644 index 0000000000..241e07a594 --- /dev/null +++ b/src/shared/validation/partialWithoutDefaults.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** + * `.partial()` for an update schema, without the creation defaults. zod 4 still applies a + * field's `.default()` under `.partial()`, so a PATCH that omits the field gets the default + * back and the merge that follows overwrites the stored value (renaming a disabled rule + * turned it back on). Unwrapping the defaults first keeps an absent field absent. + * `.extend()` keeps the source object's unknown-key policy (`.strict()` stays strict), and + * the parsed type is the one `.partial()` already gives: every field optional. + */ +export function partialWithoutDefaults( + schema: z.ZodObject +): ReturnType["partial"]> { + const unwrapped: Record = {}; + for (const [key, field] of Object.entries(schema.shape)) { + if (field instanceof z.ZodDefault) unwrapped[key] = field.unwrap(); + } + return schema.extend(unwrapped).partial() as ReturnType["partial"]>; +} diff --git a/src/shared/validation/schemas/proxy.ts b/src/shared/validation/schemas/proxy.ts index 53b8535be3..9fc0391652 100644 --- a/src/shared/validation/schemas/proxy.ts +++ b/src/shared/validation/schemas/proxy.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { partialWithoutDefaults } from "@/shared/validation/partialWithoutDefaults"; import { ACCOUNT_FALLBACK_STRATEGY_VALUES, ROUTING_STRATEGY_VALUES, @@ -13,6 +14,7 @@ import { isForbiddenCustomHeaderName, } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; +import { PROXY_REGISTRY_STATUS_VALUES } from "@/shared/constants/proxyRegistryStatus"; export const proxyConfigSchema = z .object({ @@ -115,7 +117,9 @@ export const proxyRegistryFieldsSchema = z password: z.string().optional(), region: z.string().trim().max(64).nullable().optional(), notes: z.string().trim().max(1000).nullable().optional(), - status: z.enum(["active", "inactive", "dead"]).optional().default("active"), + // No default: zod 4 applies it under .partial() too, which rewrote the stored status + // on every update or import that omitted it. New rows still start active in the DB. + status: z.enum(PROXY_REGISTRY_STATUS_VALUES).optional(), source: z .enum([ "manual", @@ -128,7 +132,9 @@ export const proxyRegistryFieldsSchema = z .optional(), // Address-family egress policy (#3777): "auto" keeps the prior dual-stack behavior; // "ipv4"/"ipv6" pin the connection to that family (no v4 leak under an IPv6-only proxy). - family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"), + // Defaulted to "auto" only by createProxyRegistrySchema: an update or a re-import that + // omits it keeps the stored family. + family: z.enum(["auto", "ipv4", "ipv6"]).optional(), }) .strict(); @@ -141,12 +147,12 @@ export const createProxyRegistrySchema = proxyRegistryFieldsSchema ) .optional() .default("http"), + family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"), assignment: inlineProxyAssignmentSchema.optional(), }) .strict(); -export const updateProxyRegistrySchema = proxyRegistryFieldsSchema - .partial() +export const updateProxyRegistrySchema = partialWithoutDefaults(proxyRegistryFieldsSchema) .extend({ id: z.string().trim().min(1, "id is required"), assignment: inlineProxyAssignmentSchema.optional(), diff --git a/src/shared/validation/schemas/reasoningRouting.ts b/src/shared/validation/schemas/reasoningRouting.ts index e2a0deaca9..fd2dcaff55 100644 --- a/src/shared/validation/schemas/reasoningRouting.ts +++ b/src/shared/validation/schemas/reasoningRouting.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { partialWithoutDefaults } from "@/shared/validation/partialWithoutDefaults"; export const reasoningRuleScopeSchema = z.enum([ "global", @@ -81,9 +82,9 @@ function validateReasoningRule( export const createReasoningRoutingRuleSchema = reasoningRoutingRuleObjectSchema.superRefine(validateReasoningRule); -export const updateReasoningRoutingRuleSchema = reasoningRoutingRuleObjectSchema - .partial() - .refine((value) => Object.keys(value).length > 0, "No fields to update"); +export const updateReasoningRoutingRuleSchema = partialWithoutDefaults( + reasoningRoutingRuleObjectSchema +).refine((value) => Object.keys(value).length > 0, "No fields to update"); export const simulateReasoningRoutingSchema = z.object({ model: z.string().trim().min(1).max(500), diff --git a/stryker.conf.json b/stryker.conf.json index 6feacd68f8..9ad8f37a7f 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -234,6 +234,7 @@ "tests/unit/combo/combo-exhausted-skip.test.ts", "tests/unit/combo/combo-target-timeout-standards.test.ts", "tests/unit/combo/effective-max-concurrency.test.ts", + "tests/unit/combo/protected-priority-stop-status-13439.test.ts", "tests/unit/combo/quota-connection-eligibility.test.ts", "tests/unit/combo/quota-weighted-stale-402.test.ts", "tests/unit/combo/quota-weighted-strategy.test.ts", @@ -247,6 +248,7 @@ "tests/unit/correctness/sanitizers.property.test.ts", "tests/unit/cursor-renewal.test.ts", "tests/unit/custom-model-target-format.test.ts", + "tests/unit/daily-reset-dst-gap.test.ts", "tests/unit/db-reset-module-state.test.ts", "tests/unit/db-server-tool-executions-migration.test.ts", "tests/unit/db/stats-dbstat-optional.test.ts", @@ -370,9 +372,11 @@ "tests/unit/resilience-connections.test.ts", "tests/unit/responses-handler.test.ts", "tests/unit/responses-passthrough-openai-compatible.test.ts", + "tests/unit/retry-after-provenance.test.ts", "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", "tests/unit/route-guard-acp-agents-local-only.test.ts", + "tests/unit/route-guard-cli-tools-settings-local-only.test.ts", "tests/unit/route-guard-cursor-agent-availability.test.ts", "tests/unit/route-guard-cursor-refresh.test.ts", "tests/unit/route-guard-forge-jcode-settings-local-only.test.ts", @@ -382,6 +386,7 @@ "tests/unit/route-guard-private-lan.test.ts", "tests/unit/route-guard-provider-login-local-only.test.ts", "tests/unit/route-guard-qwen-settings-local-only.test.ts", + "tests/unit/route-guard-skills-execute-local-only.test.ts", "tests/unit/router-strategies.test.ts", "tests/unit/routing-adaptive-e2e.test.ts", "tests/unit/rule12-error-sanitization-sweep.test.ts", @@ -408,6 +413,7 @@ "tests/unit/sse-auth.test.ts", "tests/unit/stable-json.test.ts", "tests/unit/stream-early-eof-breaker.test.ts", + "tests/unit/stream-recovery-toolcall.test.ts", "tests/unit/stream-readiness.test.ts", "tests/unit/strict-random-deck.test.ts", "tests/unit/strip-reasoning-header.test.ts", diff --git a/tests/integration/batch-e2e-rate-limit.test.ts b/tests/integration/batch-e2e-rate-limit.test.ts index 3460efbf95..686262d100 100644 --- a/tests/integration/batch-e2e-rate-limit.test.ts +++ b/tests/integration/batch-e2e-rate-limit.test.ts @@ -281,6 +281,12 @@ async function removeDirWithRetry(dir: string) { const relay = createFakeEmbeddingRelay(); let app: ReturnType; const RELAY_BASE = `http://127.0.0.1:${RELAY_PORT}`; +// The `/v1/files` + `/v1/batches` flow is owner-scoped: a file uploaded with no +// key has no owner, and a null-owner record is denied to every non-session +// caller (GHSA-2jm2-mpx8-6523 / GHSA-m3hp-hq9g-fpmv). Mint a real API key +// through the management API (open bootstrap mode, same path that seeds the +// provider node) and present it on every `/v1` call below. +let clientAuthHeaders: Record = {}; test.before(async () => { await relay.start(); @@ -307,6 +313,17 @@ test.before(async () => { `Failed to create provider node: ${nodeResp.status} ${JSON.stringify(nodeBody)}` ); } + + const keyResp = await fetch(`${app.baseUrl}/api/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Batch E2E Test Key" }), + }); + const keyBody = (await keyResp.json().catch(() => null)) as { key?: string } | null; + if (!keyResp.ok || !keyBody?.key) { + throw new Error(`Failed to create API key: ${keyResp.status} ${JSON.stringify(keyBody)}`); + } + clientAuthHeaders = { Authorization: `Bearer ${keyBody.key}` }; }); test.after(async () => { @@ -348,6 +365,7 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn const uploadResp = await fetch(`${app.baseUrl}/api/v1/files`, { method: "POST", + headers: clientAuthHeaders, body: formData, }); assert.match( @@ -362,7 +380,7 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn // 2. Create batch via HTTP POST const batchResp = await fetch(`${app.baseUrl}/api/v1/batches`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...clientAuthHeaders }, body: JSON.stringify({ input_file_id: fileId, endpoint: "/v1/embeddings", @@ -381,7 +399,9 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn while (attempts < maxAttempts) { await sleep(2_000); attempts++; - const sr = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`); + const sr = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`, { + headers: clientAuthHeaders, + }); const text = await sr.text(); let sb: BatchResponse; try { @@ -433,7 +453,9 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn ); // 5. Verify batch results - const finalResp = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`); + const finalResp = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`, { + headers: clientAuthHeaders, + }); const finalBody = await readJsonForTest(finalResp, "Final batch fetch", app); assert.equal( finalBody.request_counts?.completed, diff --git a/tests/integration/files-api-limit-validation.test.ts b/tests/integration/files-api-limit-validation.test.ts index a697e07967..da24604309 100644 --- a/tests/integration/files-api-limit-validation.test.ts +++ b/tests/integration/files-api-limit-validation.test.ts @@ -1,9 +1,25 @@ -import { describe, it } from "node:test"; +import { describe, it, before } from "node:test"; import assert from "node:assert"; -import { createFile, deleteFile } from "@/lib/db/files"; -import { GET, parseFilesListQuery } from "@/app/api/v1/files/route"; + +// `GET /v1/files` fails closed for a caller that is neither an API key nor a +// dashboard session (GHSA-m3hp-hq9g-fpmv), so the HTTP cases below present a +// real key: the subject here is limit validation, not auth. +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "files-limit-validation-secret"; + +const { createFile, deleteFile } = await import("@/lib/db/files"); +const { createApiKey } = await import("@/lib/db/apiKeys"); +const { GET, parseFilesListQuery } = await import("@/app/api/v1/files/route"); + +let authHeaders: Record = {}; +let apiKeyId = ""; describe("GET /v1/files limit validation", () => { + before(async () => { + const key = await createApiKey("files-limit-validation", "machine-files-limit", []); + apiKeyId = key.id; + authHeaders = { Authorization: `Bearer ${key.key}` }; + }); + it("defaults to 20 when limit is absent", () => { const parsed = parseFilesListQuery(new URLSearchParams("order=asc")); @@ -43,6 +59,7 @@ describe("GET /v1/files limit validation", () => { purpose: "assistants", content: Buffer.from("a"), mimeType: "text/plain", + apiKeyId, }), createFile({ bytes: 1, @@ -50,12 +67,15 @@ describe("GET /v1/files limit validation", () => { purpose: "assistants", content: Buffer.from("b"), mimeType: "text/plain", + apiKeyId, }), ]; try { const response = await GET( - new Request("http://localhost/v1/files?limit=1&purpose=assistants") + new Request("http://localhost/v1/files?limit=1&purpose=assistants", { + headers: authHeaders, + }) ); assert.equal(response.status, 200); const body = await response.json(); @@ -68,10 +88,21 @@ describe("GET /v1/files limit validation", () => { }); it("returns 400 over HTTP for an invalid limit instead of listing files", async () => { - const response = await GET(new Request("http://localhost/v1/files?limit=-1")); + const response = await GET( + new Request("http://localhost/v1/files?limit=-1", { headers: authHeaders }) + ); assert.equal(response.status, 400); const body = await response.json(); assert.equal(body.error.type, "invalid_request_error"); }); + + it("rejects an anonymous list with 401 before the limit is even looked at (GHSA-m3hp-hq9g-fpmv)", async () => { + const response = await GET(new Request("http://localhost/v1/files?limit=1")); + + assert.equal(response.status, 401); + const body = await response.json(); + assert.equal(body.error.message, "Authentication required"); + assert.equal(body.error.type, "authentication_error"); + }); }); diff --git a/tests/unit/12627-catalog-inflight-timeout.test.ts b/tests/unit/12627-catalog-inflight-timeout.test.ts index 62abeb4282..15bfeb9a6b 100644 --- a/tests/unit/12627-catalog-inflight-timeout.test.ts +++ b/tests/unit/12627-catalog-inflight-timeout.test.ts @@ -30,15 +30,18 @@ test.afterEach(() => { delete process.env.CATALOG_BUILD_TIMEOUT_MS; }); -test("#12627 cold hung rebuild times out instead of waiting forever", async () => { - await assert.rejects( - catalogCache.resolveCachedCatalogResponse( - request(), - { corsHeaders: {}, diagnosticHeaders: {} }, - neverResolves as (req: Request) => Promise - ), - /catalog_build_timeout/ +test("#12627 cold hung rebuild returns retryable 503", async () => { + const res = await catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise ); + assert.equal(res.status, 503); + assert.equal(res.headers.get("Retry-After"), "1"); + assert.equal(res.headers.get("x-omniroute-catalog"), "build-timeout"); + const body = await res.json(); + assert.equal(body.error.message, "catalog_build_timeout"); + assert.equal(body.error.code, "service_unavailable"); }); test("#12627 timeout serves last-good 200 when a prior build succeeded", async () => { @@ -59,3 +62,95 @@ test("#12627 timeout serves last-good 200 when a prior build succeeded", async ( assert.equal(await second.text(), "good"); assert.equal(second.headers.get("x-omniroute-catalog"), "last-good"); }); + +test("cold real build error still rejects (never masked as 503)", async () => { + const err = new Error("boom"); + catalogCache.__forceCatalogInFlightRejectionForTest(request(), err); + await assert.rejects( + catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + async () => payload("unused") + ), + /boom/ + ); +}); + +test("rapid retry joins the orphaned build (one builder run, two 503s)", async () => { + const p1 = catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + const p2 = catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + const [r1, r2] = await Promise.all([p1, p2]); + assert.equal(r1.status, 503); + assert.equal(r2.status, 503); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1); +}); + +test("diagnostic header merges without case duplicates", async () => { + const res = await catalogCache.resolveCachedCatalogResponse( + request(), + { corsHeaders: { "X-Omniroute-Catalog": "stale-value" }, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + assert.equal(res.status, 503); + const raw = [...res.headers.entries()].filter(([k]) => k.toLowerCase() === "x-omniroute-catalog"); + assert.equal(raw.length, 1); + assert.equal(raw[0][1], "build-timeout"); +}); + +test("slow build converging after two timeouts serves 200 on next retry", async () => { + const slowBuilder = async () => { + await new Promise((r) => setTimeout(r, 120)); + return payload("late-good"); + }; + const first = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, slowBuilder + ); + assert.equal(first.status, 503); + const second = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, slowBuilder + ); + assert.equal(second.status, 503); + await new Promise((r) => setTimeout(r, 200)); + const third = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, slowBuilder + ); + assert.equal(third.status, 200); + assert.equal(await third.text(), "late-good"); +}); + +test("eternally hung build is replaced, never pinned", async () => { + process.env.CATALOG_BUILD_TIMEOUT_MS = "20"; + try { + const r1 = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + assert.equal(r1.status, 503); + const r2 = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + assert.equal(r2.status, 503); + const r3 = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, + neverResolves as (req: Request) => Promise + ); + assert.equal(r3.status, 503); + const r4 = await catalogCache.resolveCachedCatalogResponse( + request(), { corsHeaders: {}, diagnosticHeaders: {} }, + async () => payload("fresh") + ); + assert.equal(catalogCache.__getCatalogBuilderRunsForTest() >= 2, true); + assert.equal([503, 200].includes(r4.status), true); + } finally { + process.env.CATALOG_BUILD_TIMEOUT_MS = "40"; + } +}); diff --git a/tests/unit/authz/route-guard-skills-collect.test.ts b/tests/unit/authz/route-guard-skills-collect.test.ts index 795fc3f59e..03b8b80df8 100644 --- a/tests/unit/authz/route-guard-skills-collect.test.ts +++ b/tests/unit/authz/route-guard-skills-collect.test.ts @@ -21,12 +21,16 @@ test("isLocalOnlyPath: /api/skills/collect/ prefix is local-only (Hard Rules #15 }); test("isLocalOnlyPath: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => { - // Only the spawn-capable collect/* subtree is loopback-locked. The rest of the - // skills surface (registry install, marketplace, skillssh) already gates on + // Only the spawn-capable subtrees are loopback-locked. The rest of the skills + // surface (registry list/delete, marketplace, skillssh) gates on // requireManagementAuth() and must remain reachable remotely. + // (/api/skills/install used to be the negative control here, but it can alias + // the sandboxed execute_command built-in and became LOCAL_ONLY under + // GHSA-jx89-f37j-pq89 — see tests/unit/route-guard-skills-execute-local-only.test.ts.) assert.equal(isLocalOnlyPath("/api/skills"), false); - assert.equal(isLocalOnlyPath("/api/skills/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace"), false); assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/skillssh/install"), false); }); test("isLocalOnlyBypassableByManageScope: /api/skills/collect/ is NOT bypassable (defence in depth)", () => { diff --git a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts index f28e4e8c80..ea7bef6dc8 100644 --- a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts +++ b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts @@ -90,11 +90,33 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t "/api/tunnels/tailscale/install", "/api/tunnels/tailscale/login", "/api/tunnels/tailscale/start-daemon", + // GHSA-35fw-cv32-2373: the 14 cli-tools routes that reach the same + // getCliRuntimeStatus() / detectAllTools() spawn as their gated siblings. + "/api/cli-tools/all-statuses", + "/api/cli-tools/claude-settings", + "/api/cli-tools/cline-settings", + "/api/cli-tools/codewhale-settings", + "/api/cli-tools/codex-settings", + "/api/cli-tools/crush-settings", + "/api/cli-tools/deepseek-tui-settings", + "/api/cli-tools/detect", + "/api/cli-tools/droid-settings", + "/api/cli-tools/kilo-settings", + "/api/cli-tools/openclaw-settings", + "/api/cli-tools/pi-settings", + "/api/cli-tools/smelt-settings", + "/api/cli-tools/status", + // GHSA-jx89-f37j-pq89: skills handler registration + execution reach the + // sandbox container spawn transitively. + "/api/skills/install", + "/api/skills/executions", ]) { assert.ok( SPAWN_CAPABLE_PREFIXES.includes(prefix), `SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction` ); } - assert.equal(SPAWN_CAPABLE_PREFIXES.length, 20); + // 20 at extraction time + 14 (GHSA-35fw-cv32-2373) + 2 (GHSA-jx89-f37j-pq89). + // qwen-settings is the one pre-existing entry not enumerated above. + assert.equal(SPAWN_CAPABLE_PREFIXES.length, 36); }); diff --git a/tests/unit/batch-cancel-session-auth-scope.test.ts b/tests/unit/batch-cancel-session-auth-scope.test.ts new file mode 100644 index 0000000000..120d3b19f2 --- /dev/null +++ b/tests/unit/batch-cancel-session-auth-scope.test.ts @@ -0,0 +1,133 @@ +/** + * `POST /api/v1/batches/[id]/cancel` rejected the dashboard's own + * session-authenticated caller as "Batch not found" (404) for any batch + * owned by a non-null api_key_id -- which in practice is every batch created + * through the default `env-key`, i.e. every real batch on the instance. + * Cancelling from the dashboard silently did nothing. + * + * Root cause: the route carried its own inline ownership check + * (`batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId`) instead of the + * canonical rule that `batches/[id]/route.ts` (GET/DELETE) and + * `deleteCompletedBatches()` (GHSA-wvxc-jp3v-5mg5) already share: session auth + * is the instance-wide operator, able to act on any record regardless of which + * API key owns it. The inline check never granted that exemption, so a + * session-authenticated caller (`apiKeyId === null`) was treated as a mismatched + * key the instant `batch.apiKeyId` was non-null. + * + * This test proves the fix at the ownership-decision boundary — the rule now + * shared as `canAccessOwnedRecord()` in `_helpers/apiKeyScope.ts` — against a + * batch shaped exactly like the two that were actually stuck in production + * (`api_key_id: "env-key"`), and proves the route source no longer contains the + * buggy inline check. The route-level proof (a real session cookie against the + * real handler) lives in tests/unit/files-batches-ownership-2jm2-m3hp.test.ts. + * + * Originally contributed in PR #13683 (@hartmark); folded into the + * GHSA-2jm2-mpx8-6523 / GHSA-m3hp-hq9g-fpmv fix, which subsumes it. + * + * Run with: + * node --import tsx/esm --test tests/unit/batch-cancel-session-auth-scope.test.ts + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Self-isolating: DATA_DIR points at a fresh temp dir BEFORE any `@/lib/db/*` +// module loads, so this file never touches ~/.omniroute. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "batch-cancel-session-scope-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createFile } = await import("../../src/lib/db/files.ts"); +const { createBatch } = await import("../../src/lib/db/batches.ts"); +const { canAccessOwnedRecord } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); + +function seedBatch(apiKeyId: string | null, status: "validating" | "in_progress", tag: string) { + const file = createFile({ + bytes: 10, + filename: `cancel-scope-${tag}.jsonl`, + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId, + }); + return createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + status, + apiKeyId, + }); +} + +after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +describe("cancel route ownership scoping", () => { + it("session auth (dashboard) may cancel a batch owned by an API key", () => { + const batch = seedBatch("env-key", "in_progress", "a1"); + + // Exactly the check cancel/route.ts now runs: `!canAccessOwnedRecord(scope, batch.apiKeyId)` + const allowed = canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, batch.apiKeyId); + + assert.equal(allowed, true, "the operator's dashboard must be able to cancel any batch"); + }); + + it("an unrelated API key may not cancel someone else's batch", () => { + const batch = seedBatch("env-key", "validating", "a2"); + + const allowed = canAccessOwnedRecord( + { isSessionAuth: false, apiKeyId: "other-key" }, + batch.apiKeyId + ); + + assert.equal(allowed, false, "a foreign API key must not be able to cancel this batch"); + }); + + it("the owning API key may cancel its own batch", () => { + const batch = seedBatch("key-owns-this", "validating", "a3"); + + const allowed = canAccessOwnedRecord( + { isSessionAuth: false, apiKeyId: "key-owns-this" }, + batch.apiKeyId + ); + + assert.equal(allowed, true, "the owning API key must be able to cancel its own batch"); + }); + + it("the original buggy inline check would have rejected the session-auth caller", () => { + const batch = seedBatch("env-key", "in_progress", "a4"); + + // This is the exact predicate cancel/route.ts used to run before the fix. + const apiKeyId: string | null = null; // session auth + const rejectedByOldCheck = !batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId); + + assert.equal( + rejectedByOldCheck, + true, + "documents the regression: the old inline check 404'd every dashboard cancel" + ); + }); +}); + +describe("the route uses the shared ownership rule instead of its old inline predicate", () => { + it("cancel/route.ts no longer carries the buggy apiKeyId !== null inline check", async () => { + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const src = readFileSync( + fileURLToPath(new URL("../../src/app/api/v1/batches/[id]/cancel/route.ts", import.meta.url)), + "utf8" + ); + assert.ok( + !/batch\.apiKeyId\s*!==\s*null\s*&&\s*batch\.apiKeyId\s*!==\s*apiKeyId/.test(src), + "the route still carries the old inline ownership check that 404s session auth" + ); + assert.ok( + /canAccessOwnedRecord\(\s*scope\s*,\s*batch\.apiKeyId\s*\)/.test(src), + "the route must delegate ownership to the shared canAccessOwnedRecord helper" + ); + }); +}); diff --git a/tests/unit/batch-deletion-route-logic.test.ts b/tests/unit/batch-deletion-route-logic.test.ts index b486145725..e3043fbc58 100644 --- a/tests/unit/batch-deletion-route-logic.test.ts +++ b/tests/unit/batch-deletion-route-logic.test.ts @@ -1,9 +1,18 @@ import { test } from "node:test"; import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; // Tests for the business logic embedded in DELETE route handlers. // These verify every code path without importing Next.js route modules -// (which pull in pino/thread-stream — broken on Node 26). +// (which pull in pino/thread-stream — broken on Node 26). The ownership rule is +// the REAL shared helper, not a local copy: a copy drifted from production once +// (v3.8.4 tightened the copy, production stayed open — GHSA-2jm2-mpx8-6523). +// The helper's module pulls in the DB layer, so isolate DATA_DIR before it loads. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "batch-deletion-route-logic-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const { canAccessOwnedRecord } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); const TERMINAL = ["completed", "failed", "cancelled", "expired"]; @@ -12,15 +21,17 @@ function scopeCheck( recordApiKeyId: string | null | undefined, apiKeyId: string | null ): boolean { - if (isSessionAuth) return true; - if (recordApiKeyId === null || recordApiKeyId === undefined) return apiKeyId !== null; - return recordApiKeyId === apiKeyId; + return canAccessOwnedRecord({ isSessionAuth, apiKeyId }, recordApiKeyId); } function canDeleteBatch(status: string): boolean { return TERMINAL.includes(status); } +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + test("scopeCheck — session auth always passes", () => { assert.strictEqual(scopeCheck(true, "key-1", "key-1"), true); assert.strictEqual(scopeCheck(true, "key-1", "different-key"), true); @@ -28,11 +39,11 @@ test("scopeCheck — session auth always passes", () => { assert.strictEqual(scopeCheck(true, undefined, null), true); }); -test("scopeCheck — null record ApiKeyId requires an authenticated API key", () => { - assert.strictEqual(scopeCheck(false, null, null), false); - assert.strictEqual(scopeCheck(false, null, "any-key"), true); - assert.strictEqual(scopeCheck(false, undefined, null), false); - assert.strictEqual(scopeCheck(false, undefined, "any-key"), true); +test("scopeCheck — a null-owner record is denied to every non-session caller (GHSA-2jm2-mpx8-6523)", () => { + assert.strictEqual(scopeCheck(false, null, null), false, "anonymous"); + assert.strictEqual(scopeCheck(false, null, "any-key"), false, "any authenticated key"); + assert.strictEqual(scopeCheck(false, undefined, null), false, "anonymous, undefined owner"); + assert.strictEqual(scopeCheck(false, undefined, "any-key"), false, "any key, undefined owner"); }); test("scopeCheck — matching apiKeyId passes", () => { diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index ce765a8e12..fa65b1e952 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -815,7 +815,7 @@ test("Files and batches routes expose explicit CORS preflight handlers", async ( } }); -test("Batch by-id route exposes ownerless records to anonymous requests", async () => { +test("Batch by-id route hides ownerless records from anonymous requests (GHSA-2jm2-mpx8-6523)", async () => { const file = createFile({ bytes: 2, filename: "ownerless.jsonl", @@ -830,15 +830,18 @@ test("Batch by-id route exposes ownerless records to anonymous requests", async apiKeyId: null, }); + // A null owner is unattributable: only the operator's dashboard session may + // read it. An anonymous caller (no key, no session) gets the same 404 a + // foreign key gets — never the record. const response = await batchByIdRoute.GET( new Request(`http://localhost/api/v1/batches/${batch.id}`), { params: Promise.resolve({ id: batch.id }) } ); const body = await response.json(); - assert.strictEqual(response.status, 200); - assert.strictEqual(body.id, batch.id); - assert.strictEqual(body.status, "validating"); + assert.strictEqual(response.status, 404); + assert.strictEqual(body.error?.message, "Batch not found"); + assert.strictEqual(body.id, undefined, "the ownerless record must not be returned"); }); test("Batch Cancel API", async () => { diff --git a/tests/unit/batches-delete-completed-route-scope.test.ts b/tests/unit/batches-delete-completed-route-scope.test.ts index 6e36914e94..76233cac5c 100644 --- a/tests/unit/batches-delete-completed-route-scope.test.ts +++ b/tests/unit/batches-delete-completed-route-scope.test.ts @@ -62,6 +62,13 @@ async function sessionCookie(): Promise { return `auth_token=${jwt}`; } +/** + * `label` names the seeded batch's `.jsonl` file. Keep it word-shaped or under 10 chars: the + * gitleaks generic-api-key rule reports a literal of 10+ chars with Shannon entropy >= 3.5 that + * sits right after a `key*.id` argument (the argument supplies the rule's "key" keyword). + * `wvxc-route-401` did (entropy 3.66) and became `route401` in #13729; the word-shaped + * `wvxc-route-` siblings stay under the entropy floor and are clean. + */ function seedCompletedBatch(apiKeyId: string | null, label: string) { const file = createFile({ bytes: 8, @@ -277,7 +284,8 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp it("rejects an unauthenticated request with 401 and deletes nothing", async () => { const keyB = await createApiKey("wvxc-route-401-b", "machine-wvxc-401", []); - const seeded = seedCompletedBatch(keyB.id, "wvxc-route-401"); + // short label: see the seedCompletedBatch docblock (#13729) + const seeded = seedCompletedBatch(keyB.id, "route401"); const { res, body } = await callDelete({}); @@ -290,7 +298,8 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp it("returns a sanitized 500 (no stack trace, no raw SQLite message) when the sweep throws, and deletes nothing", async () => { const keyA = await createApiKey("wvxc-route-500-a", "machine-wvxc-500", []); - const own = seedCompletedBatch(keyA.id, "wvxc-route-500"); + // short label: see the seedCompletedBatch docblock (#13729) + const own = seedCompletedBatch(keyA.id, "route500"); const db = getDbInstance(); db.exec( @@ -312,7 +321,7 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp assert.ok(getBatch(own.batch.id), "a failed sweep leaves the batch row in place"); assert.strictEqual( getFileContent(own.file.id)?.toString(), - "wvxc-route-500", + "route500", "a failed sweep rolls the file content back" ); }); diff --git a/tests/unit/call-log-error-type.test.ts b/tests/unit/call-log-error-type.test.ts index 848e51952c..fc8f9df767 100644 --- a/tests/unit/call-log-error-type.test.ts +++ b/tests/unit/call-log-error-type.test.ts @@ -1,9 +1,40 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { getDbInstance } from "../../src/lib/db/core.ts"; -import { classifyCallLogError } from "../../src/lib/usage/callLogs/format.ts"; +import { getDbInstance, resetDbInstance } from "../../src/lib/db/core.ts"; +import { classifyCallLogError, toStoredErrorType } from "../../src/lib/usage/callLogs/format.ts"; import { saveCallLog } from "../../src/lib/usage/callLogs.ts"; -import { getErrorTypeBreakdown } from "../../src/lib/db/callLogStats.ts"; +import { ERROR_TYPE_CUTOVER_ISO, getErrorTypeBreakdown } from "../../src/lib/db/callLogStats.ts"; +import { getCallLogsForExport } from "../../src/lib/usage/callLogExportSource.ts"; +import { toBigQueryRow } from "../../src/lib/logExport/destinations/bigquery.ts"; +import { + ERROR_TYPE_CONTRACT, + ERROR_TYPE_CONTRACT_VERSION, + PROVIDER_ERROR_TYPES, +} from "../../open-sse/services/errorClassifier.ts"; + +test.after(() => { + resetDbInstance(); +}); + +function deleteCallLogs(ids: string[]) { + const db = getDbInstance(); + const stmt = db.prepare("DELETE FROM call_logs WHERE id = ?"); + for (const id of ids) stmt.run(id); +} + +function insertRawErrorType(id: string, errorType: string | null, timestamp: string, status = 500) { + getDbInstance() + .prepare( + "INSERT INTO call_logs (id, timestamp, method, path, status, error_type, model, provider) VALUES (@id, @ts, 'POST', '/v1/chat/completions', @status, @et, 'm', 'p')" + ) + .run({ id, ts: timestamp, et: errorType, status }); +} + +function breakdownFor(ids: string[]) { + const whereClause = `WHERE id IN (${ids.map((_, i) => `@id${i}`).join(", ")})`; + const params = Object.fromEntries(ids.map((id, i) => [`id${i}`, id])); + return getErrorTypeBreakdown(whereClause, params); +} test("call_logs table has error_type column", () => { const db = getDbInstance(); @@ -20,10 +51,48 @@ test("classifyCallLogError maps status+body to the provider error family", () => assert.equal(classifyCallLogError(401, "bad key", "openai"), "unauthorized"); }); -test("classifyCallLogError classifies only failures", () => { +test("classifyCallLogError: successes stay null, unclassifiable failures become unknown", () => { + // Successes (with or without a body) carry no family. assert.equal(classifyCallLogError(200, "", "openai"), null); assert.equal(classifyCallLogError(200, "some body", "openai"), null); - assert.equal(classifyCallLogError(0, "boom", "test-provider"), null); + // status 0 = no upstream response: null without error text, a failure with it. + assert.equal(classifyCallLogError(0, "", "test-provider"), null); + assert.equal(classifyCallLogError(0, "boom", "test-provider"), "unknown"); + // An api-key provider 403 the classifier cannot place (it returns null). + assert.equal(classifyCallLogError(403, "some other 403 body", "openai"), "unknown"); + assert.equal(classifyCallLogError(418, "teapot", "openai"), "unknown"); +}); + +test("classifyCallLogError only ever returns a contract value or null", () => { + const contract = new Set([...ERROR_TYPE_CONTRACT, null]); + const samples: Array<[number, string]> = [ + [0, ""], + [0, "socket hang up"], + [200, "ok"], + [400, "context length exceeded"], + [400, "bad request"], + [401, "bad key"], + [402, "pay"], + [403, "browser_signature_banned"], + [403, "nope"], + [404, "gone"], + [422, "gcp_project_required"], + [429, "slow down"], + [503, "down"], + ]; + for (const [status, body] of samples) { + const value = classifyCallLogError(status, body, "openai"); + assert.ok(contract.has(value), `status ${status} produced out-of-contract ${String(value)}`); + } +}); + +test("error type contract version is 1 and vocabulary syncs with PROVIDER_ERROR_TYPES", () => { + assert.equal(ERROR_TYPE_CONTRACT_VERSION, 1); + assert.deepEqual( + [...ERROR_TYPE_CONTRACT].sort(), + [...Object.values(PROVIDER_ERROR_TYPES), "unknown"].sort() + ); + assert.ok(Object.isFrozen(ERROR_TYPE_CONTRACT)); }); test("classifyCallLogError extracts message from Error object", () => { @@ -33,160 +102,497 @@ test("classifyCallLogError extracts message from Error object", () => { ); }); -test("classifyCallLogError returns null for unclassifiable provider-403 (api key)", () => { - assert.equal(classifyCallLogError(403, "some other 403 body", "openai"), null); -}); - test("saveCallLog persists error_type from failure", async () => { - const db = getDbInstance(); const testId = `test-errtype-${Date.now()}`; + try { + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 402, + error: "exceeded your current quota", + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); - await saveCallLog({ - id: testId, - method: "POST", - path: "/v1/chat/completions", - status: 402, - error: "exceeded your current quota", - model: "test-model", - provider: "test-provider", - duration: 100, - tokens: { in: 10, out: 5 }, - }); - - const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as { - error_type: string | null; - }; - assert.equal(row.error_type, "quota_exhausted"); - - db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); + const row = getDbInstance() + .prepare("SELECT error_type FROM call_logs WHERE id = ?") + .get(testId) as { error_type: string | null }; + assert.equal(row.error_type, "quota_exhausted"); + } finally { + deleteCallLogs([testId]); + } }); test("saveCallLog persists null error_type for success", async () => { - const db = getDbInstance(); const testId = `test-errtype-ok-${Date.now()}`; + try { + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); - await saveCallLog({ - id: testId, - method: "POST", - path: "/v1/chat/completions", - status: 200, - model: "test-model", - provider: "test-provider", - duration: 100, - tokens: { in: 10, out: 5 }, - }); - - const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as { - error_type: string | null; - }; - assert.equal(row.error_type, null); - - db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); + const row = getDbInstance() + .prepare("SELECT error_type FROM call_logs WHERE id = ?") + .get(testId) as { error_type: string | null }; + assert.equal(row.error_type, null); + } finally { + deleteCallLogs([testId]); + } }); test("saveCallLog normalizes Error object before classifying", async () => { - const db = getDbInstance(); const testId = `test-errtype-err-${Date.now()}`; + try { + await saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: new Error("browser_signature_banned"), + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); - await saveCallLog({ - id: testId, - method: "POST", - path: "/v1/chat/completions", - status: 403, - error: new Error("browser_signature_banned"), - model: "test-model", - provider: "test-provider", - duration: 100, - tokens: { in: 10, out: 5 }, - }); - - const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as { - error_type: string | null; - }; - assert.equal(row.error_type, "fingerprint_rejection"); - - db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId); + const row = getDbInstance() + .prepare("SELECT error_type FROM call_logs WHERE id = ?") + .get(testId) as { error_type: string | null }; + assert.equal(row.error_type, "fingerprint_rejection"); + } finally { + deleteCallLogs([testId]); + } }); test("getErrorTypeBreakdown groups failures by family, excludes successes", async () => { - const db = getDbInstance(); + const stamp = Date.now(); const ids = [ - `test-errbd-q1-${Date.now()}`, - `test-errbd-q2-${Date.now()}`, - `test-errbd-s5-${Date.now()}`, - `test-errbd-403-${Date.now()}`, - `test-errbd-ok-${Date.now()}`, + `test-errbd-q1-${stamp}`, + `test-errbd-q2-${stamp}`, + `test-errbd-s5-${stamp}`, + `test-errbd-403-${stamp}`, + `test-errbd-ok-${stamp}`, ]; + const base = { + method: "POST", + path: "/v1/chat/completions", + model: "m", + provider: "test-provider", + duration: 100, + tokens: { in: 1, out: 1 }, + }; + try { + await saveCallLog({ ...base, id: ids[0], status: 402, error: "exceeded your current quota" }); + await saveCallLog({ ...base, id: ids[1], status: 402, error: "insufficient balance" }); + await saveCallLog({ ...base, id: ids[2], status: 500, error: "Internal Server Error" }); + await saveCallLog({ ...base, id: ids[3], status: 403, error: "some other 403 body" }); + await saveCallLog({ ...base, id: ids[4], status: 200 }); - await saveCallLog({ - id: ids[0], - method: "POST", - path: "/v1/chat/completions", - status: 402, - error: "exceeded your current quota", - model: "m", - provider: "test-provider", - duration: 100, - tokens: { in: 1, out: 1 }, - }); - await saveCallLog({ - id: ids[1], - method: "POST", - path: "/v1/chat/completions", - status: 402, - error: "insufficient balance", - model: "m", - provider: "test-provider", - duration: 100, - tokens: { in: 1, out: 1 }, - }); - await saveCallLog({ - id: ids[2], - method: "POST", - path: "/v1/chat/completions", - status: 500, - error: "Internal Server Error", - model: "m", - provider: "test-provider", - duration: 100, - tokens: { in: 1, out: 1 }, - }); - await saveCallLog({ - id: ids[3], - method: "POST", - path: "/v1/chat/completions", - status: 403, - error: "some other 403 body", - model: "m", - provider: "test-provider", - duration: 100, - tokens: { in: 1, out: 1 }, - }); - await saveCallLog({ - id: ids[4], - method: "POST", - path: "/v1/chat/completions", - status: 200, - model: "m", - provider: "test-provider", - duration: 100, - tokens: { in: 1, out: 1 }, - }); - - const whereClause = `WHERE id IN (${ids.map((_, i) => `@id${i}`).join(", ")})`; - const params = Object.fromEntries(ids.map((id, i) => [`id${i}`, id])); - const breakdown = getErrorTypeBreakdown(whereClause, params); - - assert.deepEqual(breakdown, [ - { errorType: "quota_exhausted", count: 2 }, - { errorType: "server_error", count: 1 }, - { errorType: "unclassified", count: 1 }, - ]); - - ids.forEach((id) => db.prepare("DELETE FROM call_logs WHERE id = ?").run(id)); + assert.deepEqual(breakdownFor(ids), [ + { errorType: "quota_exhausted", count: 2 }, + { errorType: "server_error", count: 1 }, + { errorType: "unknown", count: 1 }, + ]); + } finally { + deleteCallLogs(ids); + } }); test("getErrorTypeBreakdown with empty whereClause does not crash", () => { const breakdown = getErrorTypeBreakdown("", {}); assert.ok(Array.isArray(breakdown)); }); + +test("getErrorTypeBreakdown maps free-text history to unclassified, keeps pre_migration", () => { + const ids = ["hx-typo", "hx-old", "hx-new"]; + try { + insertRawErrorType("hx-typo", "typo_free", new Date().toISOString()); + insertRawErrorType("hx-old", null, "2026-01-01T00:00:00.000Z"); + insertRawErrorType("hx-new", null, new Date().toISOString()); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["typo_free"], undefined); // no longer leaks through as-is + assert.equal(byType["unclassified"], 2); // typo + recent NULL, merged into ONE row + assert.equal(byType["pre_migration"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("legacy NULL rows neither vanish nor double-count next to the new unknown value", async () => { + const stamp = Date.now(); + const legacyPre = `hx-legacy-pre-${stamp}`; + const legacyPost = `hx-legacy-post-${stamp}`; + const legacySuccess = `hx-legacy-ok-${stamp}`; + const vocab = `hx-vocab-${stamp}`; + const fresh = `hx-fresh-unknown-${stamp}`; + const ids = [legacyPre, legacyPost, legacySuccess, vocab, fresh]; + try { + insertRawErrorType(legacyPre, null, "2026-02-01T00:00:00.000Z", 503); + insertRawErrorType(legacyPost, null, new Date().toISOString(), 403); + insertRawErrorType(legacySuccess, null, new Date().toISOString(), 200); + insertRawErrorType(vocab, "rate_limited", new Date().toISOString(), 429); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const rows = breakdownFor(ids); + const byType = Object.fromEntries(rows.map((r) => [r.errorType, r.count])); + assert.deepEqual(byType, { + pre_migration: 1, + unclassified: 1, + rate_limited: 1, + unknown: 1, + }); + // One bucket per failure row: the breakdown total equals the failure count. + const failures = getDbInstance() + .prepare( + `SELECT COUNT(*) AS n FROM call_logs WHERE id IN (${ids.map(() => "?").join(",")}) AND (status >= 400 OR error_summary IS NOT NULL)` + ) + .get(...ids) as { n: number }; + assert.equal( + rows.reduce((sum, r) => sum + r.count, 0), + failures.n + ); + } finally { + deleteCallLogs(ids); + } +}); + +test("cutover boundary: pre_migration only before ERROR_TYPE_CUTOVER_ISO", () => { + assert.equal(ERROR_TYPE_CUTOVER_ISO, "2026-08-20"); + const ids = ["hx-b1", "hx-b2"]; + try { + insertRawErrorType("hx-b1", null, "2026-08-19T23:59:59.000Z"); + insertRawErrorType("hx-b2", null, "2026-08-20T00:00:00.000Z"); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["pre_migration"], 1); + assert.equal(byType["unclassified"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("log export keeps both legacy NULL and the new unknown error_type intact", async () => { + const stamp = Date.now(); + const legacy = `hx-export-null-${stamp}`; + const fresh = `hx-export-unknown-${stamp}`; + const db = getDbInstance(); + const before = Number( + (db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM call_logs").get() as { m: number }).m + ); + try { + insertRawErrorType(legacy, null, new Date().toISOString(), 500); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const exported = getCallLogsForExport(before, 50); + const byId = new Map(exported.map((row) => [row.record.id, row.record])); + assert.equal(byId.get(legacy)?.errorType, null); + assert.equal(byId.get(fresh)?.errorType, "unknown"); + + const exportedAt = new Date().toISOString(); + assert.equal(toBigQueryRow(byId.get(legacy)!, exportedAt).error_type, null); + assert.equal(toBigQueryRow(byId.get(fresh)!, exportedAt).error_type, "unknown"); + } finally { + deleteCallLogs([legacy, fresh]); + } +}); + +test("getErrorTypeBreakdown maps free-text history to unclassified, keeps pre_migration", () => { + const ids = ["hx-typo", "hx-old", "hx-new"]; + try { + insertRawErrorType("hx-typo", "typo_free", new Date().toISOString()); + insertRawErrorType("hx-old", null, "2026-01-01T00:00:00.000Z"); + insertRawErrorType("hx-new", null, new Date().toISOString()); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["typo_free"], undefined); // no longer leaks through as-is + assert.equal(byType["unclassified"], 2); // typo + recent NULL, merged into ONE row + assert.equal(byType["pre_migration"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("legacy NULL rows neither vanish nor double-count next to the new unknown value", async () => { + const stamp = Date.now(); + const legacyPre = `hx-legacy-pre-${stamp}`; + const legacyPost = `hx-legacy-post-${stamp}`; + const legacySuccess = `hx-legacy-ok-${stamp}`; + const vocab = `hx-vocab-${stamp}`; + const fresh = `hx-fresh-unknown-${stamp}`; + const ids = [legacyPre, legacyPost, legacySuccess, vocab, fresh]; + try { + insertRawErrorType(legacyPre, null, "2026-02-01T00:00:00.000Z", 503); + insertRawErrorType(legacyPost, null, new Date().toISOString(), 403); + insertRawErrorType(legacySuccess, null, new Date().toISOString(), 200); + insertRawErrorType(vocab, "rate_limited", new Date().toISOString(), 429); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const rows = breakdownFor(ids); + const byType = Object.fromEntries(rows.map((r) => [r.errorType, r.count])); + assert.deepEqual(byType, { + pre_migration: 1, + unclassified: 1, + rate_limited: 1, + unknown: 1, + }); + // One bucket per failure row: the breakdown total equals the failure count. + const failures = getDbInstance() + .prepare( + `SELECT COUNT(*) AS n FROM call_logs WHERE id IN (${ids.map(() => "?").join(",")}) AND (status >= 400 OR error_summary IS NOT NULL)` + ) + .get(...ids) as { n: number }; + assert.equal( + rows.reduce((sum, r) => sum + r.count, 0), + failures.n + ); + } finally { + deleteCallLogs(ids); + } +}); + +test("cutover boundary: pre_migration only before ERROR_TYPE_CUTOVER_ISO", () => { + assert.equal(ERROR_TYPE_CUTOVER_ISO, "2026-08-20"); + const ids = ["hx-b1", "hx-b2"]; + try { + insertRawErrorType("hx-b1", null, "2026-08-19T23:59:59.000Z"); + insertRawErrorType("hx-b2", null, "2026-08-20T00:00:00.000Z"); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["pre_migration"], 1); + assert.equal(byType["unclassified"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("log export keeps both legacy NULL and the new unknown error_type intact", async () => { + const stamp = Date.now(); + const legacy = `hx-export-null-${stamp}`; + const fresh = `hx-export-unknown-${stamp}`; + const db = getDbInstance(); + const before = Number( + (db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM call_logs").get() as { m: number }).m + ); + try { + insertRawErrorType(legacy, null, new Date().toISOString(), 500); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const exported = getCallLogsForExport(before, 50); + const byId = new Map(exported.map((row) => [row.record.id, row.record])); + assert.equal(byId.get(legacy)?.errorType, null); + assert.equal(byId.get(fresh)?.errorType, "unknown"); + + const exportedAt = new Date().toISOString(); + assert.equal(toBigQueryRow(byId.get(legacy)!, exportedAt).error_type, null); + assert.equal(toBigQueryRow(byId.get(fresh)!, exportedAt).error_type, "unknown"); + } finally { + deleteCallLogs([legacy, fresh]); + } +}); + +test("toStoredErrorType: contract values pass, null stays null, anything else is unknown", () => { + for (const value of ERROR_TYPE_CONTRACT) { + assert.equal(toStoredErrorType(value), value); + } + assert.equal(toStoredErrorType(null), null); + assert.equal(toStoredErrorType(undefined), null); + for (const value of ["typo_free", "RATE_LIMITED", "", " rate_limited", 42, {}, ["unknown"]]) { + assert.equal( + toStoredErrorType(value), + "unknown", + `expected unknown for ${JSON.stringify(value)}` + ); + } +}); + +test("saveCallLog stores unknown when the classifier emits a family outside the contract", async () => { + // Simulates vocabulary drift for real: classifyProviderError reads + // PROVIDER_ERROR_TYPES at call time, while ERROR_TYPE_CONTRACT is the frozen + // snapshot taken at load. A renamed family therefore reaches the write point + // as an out-of-contract string, and the guard must clamp it. + const types = PROVIDER_ERROR_TYPES as unknown as Record; + const original = types.SERVER_ERROR; + const id = `test-errtype-drift-${Date.now()}`; + try { + types.SERVER_ERROR = "server_error_v2"; + assert.equal(classifyCallLogError(503, "down", "test-provider"), "server_error_v2"); + await saveCallLog({ + id, + method: "POST", + path: "/v1/chat/completions", + status: 503, + error: "Service Unavailable", + model: "m", + provider: "test-provider", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + const row = getDbInstance() + .prepare("SELECT error_type FROM call_logs WHERE id = ?") + .get(id) as { + error_type: string | null; + }; + assert.equal(row.error_type, "unknown"); + } finally { + types.SERVER_ERROR = original; + deleteCallLogs([id]); + } +}); + +test("getErrorTypeBreakdown maps free-text history to unclassified, keeps pre_migration", () => { + const ids = ["hx-typo", "hx-old", "hx-new"]; + try { + insertRawErrorType("hx-typo", "typo_free", new Date().toISOString()); + insertRawErrorType("hx-old", null, "2026-01-01T00:00:00.000Z"); + insertRawErrorType("hx-new", null, new Date().toISOString()); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["typo_free"], undefined); // no longer leaks through as-is + assert.equal(byType["unclassified"], 2); // typo + recent NULL, merged into ONE row + assert.equal(byType["pre_migration"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("legacy NULL rows neither vanish nor double-count next to the new unknown value", async () => { + const stamp = Date.now(); + const legacyPre = `hx-legacy-pre-${stamp}`; + const legacyPost = `hx-legacy-post-${stamp}`; + const legacySuccess = `hx-legacy-ok-${stamp}`; + const vocab = `hx-vocab-${stamp}`; + const fresh = `hx-fresh-unknown-${stamp}`; + const ids = [legacyPre, legacyPost, legacySuccess, vocab, fresh]; + try { + insertRawErrorType(legacyPre, null, "2026-02-01T00:00:00.000Z", 503); + insertRawErrorType(legacyPost, null, new Date().toISOString(), 403); + insertRawErrorType(legacySuccess, null, new Date().toISOString(), 200); + insertRawErrorType(vocab, "rate_limited", new Date().toISOString(), 429); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const rows = breakdownFor(ids); + const byType = Object.fromEntries(rows.map((r) => [r.errorType, r.count])); + assert.deepEqual(byType, { + pre_migration: 1, + unclassified: 1, + rate_limited: 1, + unknown: 1, + }); + // One bucket per failure row: the breakdown total equals the failure count. + const failures = getDbInstance() + .prepare( + `SELECT COUNT(*) AS n FROM call_logs WHERE id IN (${ids.map(() => "?").join(",")}) AND (status >= 400 OR error_summary IS NOT NULL)` + ) + .get(...ids) as { n: number }; + assert.equal( + rows.reduce((sum, r) => sum + r.count, 0), + failures.n + ); + } finally { + deleteCallLogs(ids); + } +}); + +test("cutover boundary: pre_migration only before ERROR_TYPE_CUTOVER_ISO", () => { + assert.equal(ERROR_TYPE_CUTOVER_ISO, "2026-08-20"); + const ids = ["hx-b1", "hx-b2"]; + try { + insertRawErrorType("hx-b1", null, "2026-08-19T23:59:59.000Z"); + insertRawErrorType("hx-b2", null, "2026-08-20T00:00:00.000Z"); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["pre_migration"], 1); + assert.equal(byType["unclassified"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("log export keeps both legacy NULL and the new unknown error_type intact", async () => { + const stamp = Date.now(); + const legacy = `hx-export-null-${stamp}`; + const fresh = `hx-export-unknown-${stamp}`; + const db = getDbInstance(); + const before = Number( + (db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM call_logs").get() as { m: number }).m + ); + try { + insertRawErrorType(legacy, null, new Date().toISOString(), 500); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const exported = getCallLogsForExport(before, 50); + const byId = new Map(exported.map((row) => [row.record.id, row.record])); + assert.equal(byId.get(legacy)?.errorType, null); + assert.equal(byId.get(fresh)?.errorType, "unknown"); + + const exportedAt = new Date().toISOString(); + assert.equal(toBigQueryRow(byId.get(legacy)!, exportedAt).error_type, null); + assert.equal(toBigQueryRow(byId.get(fresh)!, exportedAt).error_type, "unknown"); + } finally { + deleteCallLogs([legacy, fresh]); + } +}); diff --git a/tests/unit/call-log-search-ghost.test.ts b/tests/unit/call-log-search-ghost.test.ts new file mode 100644 index 0000000000..b3b2ced73f --- /dev/null +++ b/tests/unit/call-log-search-ghost.test.ts @@ -0,0 +1,223 @@ +/** + * Search stats must not surface "ghost" rows, and must not hide real traffic. + * + * Always hidden: rows with a NULL provider or the '-' sentinel. + * Hidden only with SEARCH_STATS_HIDE_DELETED_CONNECTIONS on: a keyed provider whose + * provider_connections row is gone (deleted connection). Off (the default) keeps + * the historical stats, where every retained row with a provider id counts. + * Kept either way: keyed providers with a live connection (directly or through a + * registry credential fallback such as perplexity-search → perplexity) and + * keyless providers (`authType: "none"` — duckduckgo-free, searxng-search, + * anonymous context7), which are served without any provider_connections row. + * Totals and per-provider rows use the same guard, so they always agree. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-search-ghost-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mod = await import("../../src/lib/db/callLogStats.ts"); +const { SEARCH_PROVIDERS, SEARCH_CREDENTIAL_FALLBACKS } = + await import("../../open-sse/config/searchRegistry.ts"); +const analyticsRoute = await import("../../src/app/api/v1/search/analytics/route.ts"); + +const KEYLESS_IDS = Object.values(SEARCH_PROVIDERS) + .filter((provider) => provider.authType === "none") + .map((provider) => provider.id); + +let idSeq = 0; +function insertSearchLog(provider: string | null, fields: Record = {}) { + core + .getDbInstance() + .prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, duration, + tokens_in, tokens_out, cache_source, request_type, detail_state, error_summary, + request_summary, has_request_body, has_response_body, has_pipeline_details) + VALUES (@id, @timestamp, 'POST', '/v1/search', @status, 'search', @provider, @duration, + 0, 0, 'upstream', 'search', 'none', NULL, @summary, 0, 0, 0)` + ) + .run({ + id: `log-ghost-${++idSeq}`, + timestamp: new Date().toISOString(), + status: 200, + duration: 100, + summary: JSON.stringify({ query: `q-${idSeq}` }), + provider, + ...fields, + }); +} + +function insertConnection(id: string, provider: string) { + const now = new Date().toISOString(); + core + .getDbInstance() + .prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ) + .run(id, provider, now, now); +} + +test.before(() => { + core.resetDbInstance(); + insertConnection("conn-ghost-brave", "brave-search"); + insertConnection("conn-ghost-perplexity-chat", "perplexity"); + + insertSearchLog("brave-search", { duration: 50 }); + insertSearchLog("brave-search", { duration: 150, status: 502 }); + insertSearchLog("perplexity-search", { duration: 90 }); // live via credential fallback + insertSearchLog("duckduckgo-free", { duration: 70 }); // keyless, no connection row + insertSearchLog("duckduckgo-free", { duration: 30 }); + insertSearchLog("searxng-search", { duration: 40 }); // keyless, no connection row + insertSearchLog("context7", { duration: 60 }); // anonymous tier, no connection row + // Ghosts + insertSearchLog("tavily-search", { duration: 80 }); // connection deleted + insertSearchLog("-", { duration: 80 }); + insertSearchLog(null, { duration: 80 }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const FLAG = "SEARCH_STATS_HIDE_DELETED_CONNECTIONS"; + +function withFlag(value: "true" | undefined, fn: () => T): T { + const previous = process.env[FLAG]; + if (value === undefined) delete process.env[FLAG]; + else process.env[FLAG] = value; + try { + return fn(); + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } +} + +const LIVE_COUNTS: Record = { + "brave-search": 2, + "duckduckgo-free": 2, + "perplexity-search": 1, + "searxng-search": 1, + context7: 1, +}; +// Flag off: the deleted tavily-search connection still counts (historical behavior). +const HISTORICAL_COUNTS: Record = { ...LIVE_COUNTS, "tavily-search": 1 }; + +function todayStartIso(): string { + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + return todayStart.toISOString(); +} + +test("registry fixtures used here are real: keyless ids and the perplexity fallback", () => { + for (const id of ["duckduckgo-free", "searxng-search", "context7"]) { + assert.ok(KEYLESS_IDS.includes(id), `${id} is authType none in the search registry`); + } + for (const id of ["brave-search", "tavily-search", "perplexity-search"]) { + assert.equal(SEARCH_PROVIDERS[id]?.authType, "apikey", `${id} is a keyed search provider`); + } + assert.equal(SEARCH_CREDENTIAL_FALLBACKS["perplexity-search"], "perplexity"); +}); + +test("flag off (default): NULL and '-' rows are hidden, deleted-connection traffic still counts", () => { + withFlag(undefined, () => { + const stats = mod.getSearchProviderStats(); + assert.deepEqual( + Object.fromEntries(stats.map((r) => [r.provider, r.requests])), + HISTORICAL_COUNTS + ); + assert.deepEqual( + Object.fromEntries(mod.getSearchProviderCounts().map((r) => [r.provider, r.cnt])), + HISTORICAL_COUNTS + ); + const recent = mod.getRecentSearchLogs().map((r) => r.provider); + assert.equal(recent.length, 8); + assert.ok( + recent.includes("tavily-search"), + "deleted connection still listed with the flag off" + ); + assert.ok(!recent.includes("-") && !recent.includes(null as unknown as string)); + const aggregate = mod.getSearchAggregateStats(todayStartIso()); + assert.equal(aggregate.total, 8); + assert.equal( + aggregate.total, + mod.getSearchProviderCounts().reduce((sum, r) => sum + r.cnt, 0) + ); + }); +}); + +test("flag on: getSearchProviderStats keeps live + keyless providers and drops ghosts", () => { + withFlag("true", () => { + const rows = mod.getSearchProviderStats(); + const byProvider = Object.fromEntries(rows.map((r) => [r.provider, r])); + assert.deepEqual(Object.fromEntries(rows.map((r) => [r.provider, r.requests])), LIVE_COUNTS); + assert.equal(byProvider["brave-search"].avg_latency_ms, 100); + assert.equal(byProvider["duckduckgo-free"].avg_latency_ms, 50); + }); +}); + +test("flag on: getSearchProviderCounts keeps live + keyless providers, ordered by count", () => { + withFlag("true", () => { + const rows = mod.getSearchProviderCounts(); + assert.deepEqual(Object.fromEntries(rows.map((r) => [r.provider, r.cnt])), LIVE_COUNTS); + for (let i = 1; i < rows.length; i++) { + assert.ok(rows[i - 1].cnt >= rows[i].cnt, "ordered by cnt desc"); + } + }); +}); + +test("flag on: getRecentSearchLogs keeps keyless traffic and drops ghost rows", () => { + withFlag("true", () => { + const providers = mod.getRecentSearchLogs().map((r) => r.provider); + assert.equal(providers.length, 7); + for (const ghost of ["tavily-search", "-", null]) { + assert.ok(!providers.includes(ghost as string), `${String(ghost)} excluded`); + } + for (const live of Object.keys(LIVE_COUNTS)) { + assert.ok(providers.includes(live), `${live} present`); + } + }); +}); + +test("flag on: aggregate totals agree with the per-provider breakdown", () => { + withFlag("true", () => { + const stats = mod.getSearchAggregateStats(todayStartIso()); + const breakdownTotal = mod.getSearchProviderCounts().reduce((sum, r) => sum + r.cnt, 0); + assert.equal(stats.total, breakdownTotal); + assert.equal(stats.total, 7); + assert.equal(stats.today, 7); + assert.equal(stats.errors, 1); + }); +}); + +test("GET /api/v1/search/analytics: total equals the sum of byProvider counts in both modes", async () => { + for (const mode of [undefined, "true"] as const) { + const previous = process.env[FLAG]; + if (mode === undefined) delete process.env[FLAG]; + else process.env[FLAG] = mode; + try { + const response = await analyticsRoute.GET( + new Request("http://localhost/api/v1/search/analytics") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { + total: number; + byProvider: Record; + }; + const byProviderTotal = Object.values(body.byProvider).reduce((sum, p) => sum + p.count, 0); + assert.equal(body.total, byProviderTotal, `mode=${String(mode)}`); + assert.equal(body.byProvider["duckduckgo-free"]?.count, 2); + if (mode === "true") assert.equal(body.byProvider["tavily-search"], undefined); + else assert.equal(body.byProvider["tavily-search"]?.count, 1); + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } + } +}); diff --git a/tests/unit/chatcore-stream-recovery-log-wiring.test.ts b/tests/unit/chatcore-stream-recovery-log-wiring.test.ts new file mode 100644 index 0000000000..fecc02ab60 --- /dev/null +++ b/tests/unit/chatcore-stream-recovery-log-wiring.test.ts @@ -0,0 +1,114 @@ +// handleChatCore wiring of the mid-stream continuation log hooks: a real streaming request +// with stream recovery + mid-stream continuation enabled, an upstream that commits the +// holdback window and then drops, and a continuation that finishes the answer. The injected +// log must receive the release attempt line at warn and the stitched outcome at debug. +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-recovery-log-wiring-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +const { STREAM_RECOVERY } = await import("../../open-sse/config/constants.ts"); + +const ENV_KEYS = ["STREAM_RECOVERY_ENABLED", "STREAM_RECOVERY_MIDSTREAM_ENABLED"] as const; +const originalEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); +const originalFetch = globalThis.fetch; +const enc = new TextEncoder(); + +const chunk = (content: string) => + `data: ${JSON.stringify({ + id: "chatcmpl-wiring", + object: "chat.completion.chunk", + model: "gpt-4o-mini", + choices: [{ index: 0, delta: { role: "assistant", content } }], + })}\n\n`; + +before(() => { + core.resetDbInstance(); + process.env.STREAM_RECOVERY_ENABLED = "true"; + process.env.STREAM_RECOVERY_MIDSTREAM_ENABLED = "true"; +}); + +after(() => { + globalThis.fetch = originalFetch; + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("handleChatCore routes continuation logs through buildContinuationLogHooks", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) { + // Two chunks spaced past the holdback window (so the stream commits), then a silent + // cut: no finish_reason, no [DONE]. + let step = 0; + const body = new ReadableStream({ + async pull(controller) { + step += 1; + if (step === 1) controller.enqueue(enc.encode(chunk("Hello there "))); + else if (step === 2) { + await new Promise((r) => setTimeout(r, STREAM_RECOVERY.HOLDBACK_MS + 150)); + controller.enqueue(enc.encode(chunk("world"))); + } else controller.close(); + }, + }); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + return new Response(chunk("there world, nice to meet you!") + "data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + const warn: string[] = []; + const debug: string[] = []; + const log = { + info() {}, + error() {}, + warn: (tag: string, msg: string) => warn.push(`${tag} ${msg}`), + debug: (tag: string, msg: string) => debug.push(`${tag} ${msg}`), + }; + const body = { + model: "gpt-4o-mini", + stream: true, + messages: [{ role: "user", content: "hi" }], + }; + const result = await handleChatCore({ + body, + modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false }, + credentials: { apiKey: "sk-test-wiring" }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body, + headers: new Headers({ accept: "text/event-stream" }), + }, + userAgent: "unit-test", + isCombo: false, + log, + } as unknown as Parameters[0]); + + const response = (result as { response?: Response }).response; + assert.ok(response?.body, "streaming response expected"); + const text = await response.text(); + + assert.equal(calls, 2, "one upstream request plus one continuation"); + assert.match(text, /nice to meet you!/); + const recoveryWarns = warn.filter((l) => l.startsWith("STREAM_RECOVERY ")); + assert.deepEqual(recoveryWarns, ["STREAM_RECOVERY mid-stream continuation attempt 1/4"]); + assert.ok( + debug.includes( + "STREAM_RECOVERY mid-stream continuation attempt 1/4 outcome=suffix suffixChars=19" + ), + debug.filter((l) => l.startsWith("STREAM_RECOVERY")).join(" | ") + ); +}); diff --git a/tests/unit/check-route-guard-membership.test.ts b/tests/unit/check-route-guard-membership.test.ts index 146772528b..b73ea025fb 100644 --- a/tests/unit/check-route-guard-membership.test.ts +++ b/tests/unit/check-route-guard-membership.test.ts @@ -9,6 +9,7 @@ import { isSpawnCapableSource, findSpawnCapableRoutes, KNOWN_UNCLASSIFIED_SOURCE_SPAWN, + SPAWN_CAPABLE_ROUTE_ROOTS, } from "../../scripts/check/check-route-guard-membership.ts"; import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; @@ -64,11 +65,7 @@ test("flags a spawn-capable route that is NOT classified local-only (RCE-via-tun // this gate guards against. const leaky = (path: string): boolean => path.startsWith("/api/mcp/"); assert.deepEqual( - findUnclassifiedSpawnRoutes( - ["/api/mcp/tools", "/api/services/cliproxy/install"], - leaky, - {} - ), + findUnclassifiedSpawnRoutes(["/api/mcp/tools", "/api/services/cliproxy/install"], leaky, {}), ["/api/services/cliproxy/install"] ); }); @@ -76,11 +73,9 @@ test("flags a spawn-capable route that is NOT classified local-only (RCE-via-tun test("allowlisted routes are not flagged (frozen pre-existing exceptions)", () => { const leaky = (path: string): boolean => path.startsWith("/api/mcp/"); assert.deepEqual( - findUnclassifiedSpawnRoutes( - ["/api/mcp/tools", "/api/services/legacy/route"], - leaky, - { "/api/services/legacy/route": "frozen pre-existing exception" } - ), + findUnclassifiedSpawnRoutes(["/api/mcp/tools", "/api/services/legacy/route"], leaky, { + "/api/services/legacy/route": "frozen pre-existing exception", + }), [] ); }); @@ -128,7 +123,10 @@ test("6A.8 findSpawnCapableRoutes: detects real spawn-capable route.ts files", ( ]; const found = findSpawnCapableRoutes(repoRoot); for (const r of knownSpawnRoutes) { - assert.ok(found.includes(r), `expected ${r} in spawn-capable routes, found: ${found.join(", ")}`); + assert.ok( + found.includes(r), + `expected ${r} in spawn-capable routes, found: ${found.join(", ")}` + ); } }); @@ -153,6 +151,51 @@ test("#7948: /api/acp/agents (transitive execFileSync via registry) is classifie assert.equal(isLocalOnlyPath("/api/acp/agents"), true); }); +test("GHSA-35fw-cv32-2373: every cli-tools route that reaches getCliRuntimeStatus()/detectAllTools() is classified local-only", () => { + // Same transitive-spawn class as #7948: the spawn lives in + // src/shared/services/cliRuntime.ts (runProcess -> spawn) and + // src/lib/cli-helper/tool-detector.ts (execFile), never in the route file, so + // the source-scan subcheck is blind to it. Six siblings were already gated; + // these 14 called the same helper and were not. Each is now a + // SPAWN_CAPABLE_ROUTE_ROOT so subcheck 1 enforces membership going forward. + const routes = [ + "/api/cli-tools/all-statuses", + "/api/cli-tools/claude-settings", + "/api/cli-tools/cline-settings", + "/api/cli-tools/codewhale-settings", + "/api/cli-tools/codex-settings", + "/api/cli-tools/crush-settings", + "/api/cli-tools/deepseek-tui-settings", + "/api/cli-tools/detect", + "/api/cli-tools/droid-settings", + "/api/cli-tools/kilo-settings", + "/api/cli-tools/openclaw-settings", + "/api/cli-tools/pi-settings", + "/api/cli-tools/smelt-settings", + "/api/cli-tools/status", + ]; + for (const r of routes) { + assert.equal(isLocalOnlyPath(r), true, `${r} must be local-only`); + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes(`src/app${r}`), + `src/app${r} must be a SPAWN_CAPABLE_ROUTE_ROOT` + ); + } +}); + +test("GHSA-jx89-f37j-pq89: /api/skills/install + /api/skills/executions (transitive sandbox spawn) are classified local-only", () => { + // The spawn is three modules away from the route (executor.ts -> builtins.ts -> + // sandbox.ts childProcess.spawn), so the source-scan subcheck cannot see it. + // Both are now SPAWN_CAPABLE_ROUTE_ROOTs so subcheck 1 enforces membership. + for (const r of ["/api/skills/install", "/api/skills/executions"]) { + assert.equal(isLocalOnlyPath(r), true, `${r} must be local-only`); + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes(`src/app${r}`), + `src/app${r} must be a SPAWN_CAPABLE_ROUTE_ROOT` + ); + } +}); + test("6A.8: spawn-capable routes in SPAWN_CAPABLE_ROUTE_ROOTS are still all classified local-only", async () => { // The original subcheck (SPAWN_CAPABLE_ROUTE_ROOTS) must still pass. // This test is a regression guard — the new source-scan does not break the old check. diff --git a/tests/unit/check-routing-error-guard.test.ts b/tests/unit/check-routing-error-guard.test.ts new file mode 100644 index 0000000000..dd5cc11fec --- /dev/null +++ b/tests/unit/check-routing-error-guard.test.ts @@ -0,0 +1,133 @@ +/** + * #13614 — scripts/check/check-routing-error-guard.mjs (npm run check:routing-error-guard). + * Frozen swallowed catches are keyed by file + body snippet, so line shifts never break + * the gate; void-async allowlist anchors must sit next to the site they cover. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const { catchSnippet, collectSwallowedCatches, evaluateSwallowedCatches, evaluateVoidAsyncSites } = + await import("../../scripts/check/check-routing-error-guard.mjs"); + +const FILE = "open-sse/services/combo/example.ts"; +const file = (source: string, path = FILE) => ({ path, source }); + +const SWALLOW = "try {\n await work();\n} catch {\n pending = fallback;\n}\n"; + +test("a bare swallowed catch is a violation when not frozen", () => { + const { violations, stale } = evaluateSwallowedCatches([file(SWALLOW)], []); + assert.equal(violations.length, 1); + assert.match(violations[0], /example\.ts:3 :: swallowed-catch ::/); + assert.deepEqual(stale, []); +}); + +test("rethrowing catches, no-effect markers and chained .catch() are not swallows", () => { + const sources = [ + "try {\n await work();\n} catch (err) {\n log.warn(err);\n throw err;\n}\n", + "try {\n clone = r.clone();\n} catch {\n // no-effect: clone fallback\n clone = r;\n}\n", + "const quota = await fetchQuota(id).catch(() => null);\n", + ]; + assert.deepEqual(collectSwallowedCatches(sources.map((s) => file(s))), []); +}); + +test("a frozen entry survives line shifts (keyed by snippet, not line number)", () => { + const frozen = [ + { + file: FILE, + snippet: catchSnippet("\n pending = fallback;\n"), + count: 1, + reason: "fallback", + }, + ]; + const shifted = "// a new line\n// another\n\n" + SWALLOW; + assert.deepEqual(evaluateSwallowedCatches([file(SWALLOW)], frozen), { + violations: [], + stale: [], + }); + assert.deepEqual(evaluateSwallowedCatches([file(shifted)], frozen), { + violations: [], + stale: [], + }); +}); + +test("counts: a second identical swallow is new, a removed one makes the entry stale", () => { + const frozen = [{ file: FILE, snippet: "pending = fallback;", count: 1, reason: "fallback" }]; + const twice = evaluateSwallowedCatches([file(SWALLOW + SWALLOW)], frozen); + assert.equal(twice.violations.length, 1); + assert.match(twice.violations[0], /example\.ts:8 ::/); + + const gone = evaluateSwallowedCatches([file("const ok = 1;\n")], frozen); + assert.deepEqual(gone.violations, []); + assert.equal(gone.stale.length, 1); + assert.match(gone.stale[0], /frozen 1, live 0/); +}); + +test("editing a frozen catch body re-flags it (the snippet no longer matches)", () => { + const frozen = [{ file: FILE, snippet: "pending = fallback;", count: 1, reason: "fallback" }]; + const edited = SWALLOW.replace("pending = fallback;", "pending = otherFallback;"); + const result = evaluateSwallowedCatches([file(edited)], frozen); + assert.equal(result.violations.length, 1); + assert.equal(result.stale.length, 1); +}); + +test("a frozen entry without a reason is rejected", () => { + const frozen = [{ file: FILE, snippet: "pending = fallback;", count: 1, reason: " " }]; + const { violations } = evaluateSwallowedCatches([file(SWALLOW)], frozen); + assert.equal(violations.length, 1); + assert.match(violations[0], /entry needs a reason/); +}); + +const VOID_SITE = + "void (async () => {\n try {\n await persist();\n } catch (err) {\n log.warn('Failed to record Last Known Good Provider', err);\n }\n})();\n"; + +test("void async: an anchored allowlist entry covers the site", () => { + const allow = [ + { file: FILE, anchor: "Failed to record Last Known Good Provider", reason: "persist" }, + ]; + assert.deepEqual(evaluateVoidAsyncSites([file(VOID_SITE)], allow), { violations: [], stale: [] }); +}); + +test("void async: an unlisted site, a reasonless entry and an orphan entry all fail", () => { + const unlisted = evaluateVoidAsyncSites( + [file("void (async () => {\n await work();\n})();\n")], + [] + ); + assert.equal(unlisted.violations.length, 1); + assert.match(unlisted.violations[0], /example\.ts:1 :: void-async ::/); + + const reasonless = evaluateVoidAsyncSites( + [file(VOID_SITE)], + [{ file: FILE, anchor: "Failed to record Last Known Good Provider" }] + ); + assert.match(reasonless.violations[0], /needs a reason/); + + const orphan = evaluateVoidAsyncSites( + [file("const x = 1;\n")], + [{ file: "open-sse/services/combo/removed.ts", anchor: "gone", reason: "left over" }] + ); + assert.deepEqual(orphan.violations, []); + assert.deepEqual(orphan.stale, ["open-sse/services/combo/removed.ts :: gone"]); +}); + +test("void async: an anchor elsewhere in the file does not cover an unrelated site", () => { + const source = + "void (async () => {\n await work();\n})();\n" + + "\n".repeat(40) + + "// Failed to record Last Known Good Provider\n"; + const { violations } = evaluateVoidAsyncSites( + [file(source)], + [{ file: FILE, anchor: "Failed to record Last Known Good Provider", reason: "persist" }] + ); + assert.equal(violations.length, 1); +}); + +test("wired as the check:routing-error-guard npm script (not a CI job)", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { + scripts: Record; + }; + assert.equal( + pkg.scripts["check:routing-error-guard"], + "node scripts/check/check-routing-error-guard.mjs" + ); +}); diff --git a/tests/unit/client-bundle-no-server-only-10692.test.ts b/tests/unit/client-bundle-no-server-only-10692.test.ts index e29ae9d2fa..9206d61604 100644 --- a/tests/unit/client-bundle-no-server-only-10692.test.ts +++ b/tests/unit/client-bundle-no-server-only-10692.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import fs from "node:fs"; import path from "node:path"; +import { builtinModules } from "node:module"; import { fileURLToPath } from "node:url"; /** @@ -26,6 +27,20 @@ import { fileURLToPath } from "node:url"; * - **Dynamic `import()` is not followed.** It does not actually break a bundle edge (that was * tried for #10692 and failed), but it does move the module into a chunk the browser only * fetches on demand, which is a legitimate boundary for a lazily-used server path. + * + * A reached module counts as server-only when it statically imports a Node builtin the + * production bundler cannot resolve for the browser. The pinned list below (the original + * #10692 chain) stays explicit so it keeps failing loudly even if the discovery logic + * changes; everything else is found by walking the graph and checking each visited file + * for a builtin import. + * + * Builtins Next ships a browser polyfill for are tolerated when imported by their BARE name + * (`path`, `os`, `crypto`, `buffer`, …): Next's client build maps exactly those names to + * `next/dist/compiled/*` shims (`resolve.fallback` for the client compiler in + * `node_modules/next/dist/build/webpack-config.js`), so flagging them would cry wolf the + * same way counting `import type` did. The `node:` scheme is never tolerated — the client + * build has no fallback for it (`UnhandledSchemeError` on `node:fs` / `node:os` / `node:path` + * is what broke the build this guard was widened for). */ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -39,6 +54,45 @@ const SERVER_ONLY = new Set([ "open-sse/utils/tlsClient.ts", ]); +/** + * Bare builtin names Next's client build polyfills (the client `resolve.fallback` map in + * `next/dist/build/webpack-config.js`). Kept in sync by the drift test at the bottom. + */ +const NEXT_CLIENT_POLYFILLED_BUILTINS = new Set([ + "assert", + "buffer", + "constants", + "crypto", + "domain", + "events", + "http", + "https", + "os", + "path", + "process", + "punycode", + "querystring", + "stream", + "string_decoder", + "sys", + "timers", + "tty", + "util", + "vm", + "zlib", +]); + +const NODE_BUILTINS = new Set( + builtinModules.map((name) => name.replace(/^node:/, "")).filter((bare) => !bare.startsWith("_")) +); + +/** True when `specifier` names a Node builtin the browser bundle cannot resolve. */ +function isBrowserForbiddenBuiltin(specifier: string): boolean { + if (specifier.startsWith("node:")) return true; // no client fallback for the scheme + const root = specifier.split("/")[0]; // `fs/promises` → `fs` + return NODE_BUILTINS.has(root) && !NEXT_CLIENT_POLYFILLED_BUILTINS.has(root); +} + /** * Non-`"use client"` entry points that still end up in a client bundle because client * components import them. Kept explicit so the original #10692 chain stays pinned even if the @@ -60,6 +114,9 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null { } else if (specifier.startsWith("@omniroute/open-sse")) { const rest = specifier.slice("@omniroute/open-sse".length).replace(/^\//, ""); base = path.join(REPO_ROOT, "open-sse", rest); + } else if (specifier.startsWith("@omniroute/browser-pool")) { + const rest = specifier.slice("@omniroute/browser-pool".length).replace(/^\//, ""); + base = path.join(REPO_ROOT, "packages/browser-pool/src", rest); } else if (specifier.startsWith("@/")) { base = path.join(REPO_ROOT, "src", specifier.slice(2)); } else { @@ -118,29 +175,53 @@ function staticSpecifiers(source: string): string[] { } const specifierCache = new Map(); -function edgesOf(file: string): string[] { +function specifiersOf(file: string): string[] { const cached = specifierCache.get(file); if (cached) return cached; const absolute = path.join(REPO_ROOT, file); - let edges: string[] = []; + let specs: string[] = []; if (fs.existsSync(absolute)) { - edges = staticSpecifiers(fs.readFileSync(absolute, "utf8")) - .map((specifier) => resolveSpecifier(file, specifier)) - .filter((resolved): resolved is string => resolved !== null); + specs = staticSpecifiers(fs.readFileSync(absolute, "utf8")); } - specifierCache.set(file, edges); + specifierCache.set(file, specs); + return specs; +} +// Resolved edges and verdicts are cached per file: the BFS runs once per client entry and +// re-visits the same shared modules thousands of times, so re-resolving specifiers +// (fs.existsSync/statSync per candidate) on every visit made the guard ~6x slower. +const edgeCache = new Map(); +function edgesOf(file: string): string[] { + const cached = edgeCache.get(file); + if (cached) return cached; + const edges = specifiersOf(file) + .map((specifier) => resolveSpecifier(file, specifier)) + .filter((resolved): resolved is string => resolved !== null); + edgeCache.set(file, edges); return edges; } +const serverOnlyVerdictCache = new Map(); +/** True when the file is pinned server-only or itself imports a browser-forbidden builtin. */ +function isServerOnly(file: string): boolean { + const cached = serverOnlyVerdictCache.get(file); + if (cached !== undefined) return cached; + const verdict = SERVER_ONLY.has(file) || specifiersOf(file).some(isBrowserForbiddenBuiltin); + serverOnlyVerdictCache.set(file, verdict); + return verdict; +} + /** BFS over static imports; returns the first path reaching a server-only module. */ function findServerOnlyPath(entry: string): string[] | null { const seen = new Set([entry]); + if (isServerOnly(entry)) return [entry]; const queue: Array = [[entry]]; while (queue.length > 0) { const trail = queue.shift()!; for (const resolved of edgesOf(trail[trail.length - 1])) { if (seen.has(resolved)) continue; - if (SERVER_ONLY.has(resolved)) return [...trail, resolved]; + if (isServerOnly(resolved)) { + return [...trail, resolved]; + } seen.add(resolved); queue.push([...trail, resolved]); } @@ -163,7 +244,9 @@ function walk(dir: string, acc: string[] = []): string[] { function clientEntryPoints(): string[] { return walk(path.join(REPO_ROOT, "src")).filter((file) => - /^\s*["']use client["']/m.test(fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200)) + /^\s*["']use client["']/m.test( + fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200) + ) ); } @@ -184,3 +267,32 @@ test("no client entry point statically reaches server-only code", () => { "carries no runtime edge." ); }); + +test("builtin classification: node: scheme always forbidden, bare polyfilled names tolerated", () => { + for (const specifier of ["node:fs", "node:path", "node:os", "node:crypto", "fs", "fs/promises"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier); + } + for (const specifier of ["child_process", "net", "tls", "module", "worker_threads"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier); + } + for (const specifier of ["path", "os", "crypto", "buffer", "events", "util", "stream"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier); + } + for (const specifier of ["react", "@/lib/db/core", "./local", "zod"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier); + } +}); + +test("the polyfilled-builtin allowlist matches Next's client resolve.fallback", () => { + const webpackConfig = fs.readFileSync( + path.join(REPO_ROOT, "node_modules/next/dist/build/webpack-config.js"), + "utf8" + ); + for (const name of NEXT_CLIENT_POLYFILLED_BUILTINS) { + assert.match( + webpackConfig, + new RegExp(`\\b${name}: require\\.resolve\\(`), + `Next no longer polyfills "${name}" for the client — drop it from the allowlist` + ); + } +}); diff --git a/tests/unit/combo-routes-dead-keys.test.ts b/tests/unit/combo-routes-dead-keys.test.ts new file mode 100644 index 0000000000..e1c272fdb6 --- /dev/null +++ b/tests/unit/combo-routes-dead-keys.test.ts @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-dead-keys-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const createRoute = await import("../../src/app/api/combos/route.ts"); +const comboRoute = await import("../../src/app/api/combos/[id]/route.ts"); + +function makeCreateRequest(body: Record) { + return new Request("http://localhost/api/combos", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeUpdateRequest(body: Record) { + return new Request("http://localhost/api/combos/combo-1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function comboInput(name: string, config: Record) { + return { + name, + strategy: "priority", + models: [{ providerId: "claude", model: "claude-sonnet-4-6" }], + config, + }; +} + +test.beforeEach(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("POST strips the 3 dead keys and keeps a live witness", async () => { + const response = await createRoute.POST( + makeCreateRequest( + comboInput("dead-post", { + pipelineConcurrency: 4, + resetAwareEnabled: true, + resetAwareWindow: 7, + maxComboDepth: 3, + }) + ) + ); + assert.equal(response.status, 201); + const stored = (await combosDb.getComboByName("dead-post")) as { config?: Record } | null; + assert.deepEqual(stored?.config, { maxComboDepth: 3 }); +}); + +test("PUT strips the 3 dead keys and keeps the 9 live ones", async () => { + const combo = await combosDb.createCombo({ + name: "dead-put", + models: [{ provider: "claude", model: "claude-sonnet-4-6" }], + } as never); + const response = await comboRoute.PUT( + makeUpdateRequest({ + config: { + pipelineConcurrency: 4, + resetAwareEnabled: true, + resetAwareWindow: 7, + maxComboDepth: 5, + queueDepth: 10, + fallbackDelayMs: 100, + handoffProviders: ["codex"], + manifestRouting: true, + complexityAwareRouting: true, + pipeline_enabled: true, + shadowRouting: { enabled: false }, + evalRouting: { enabled: false }, + queueTimeoutMs: 5000, + }, + }), + { params: Promise.resolve({ id: (combo as { id: string }).id }) } + ); + assert.equal(response.status, 200); + const stored = (await combosDb.getComboById((combo as { id: string }).id)) as { + config?: Record; + } | null; + assert.equal(stored?.config?.pipelineConcurrency, undefined); + assert.equal(stored?.config?.resetAwareEnabled, undefined); + assert.equal(stored?.config?.resetAwareWindow, undefined); + assert.equal(stored?.config?.maxComboDepth, 5); + assert.equal(stored?.config?.queueDepth, 10); + assert.equal(stored?.config?.fallbackDelayMs, 100); + assert.deepEqual(stored?.config?.handoffProviders, ["codex"]); + assert.equal(stored?.config?.manifestRouting, true); + assert.equal(stored?.config?.complexityAwareRouting, true); + assert.equal(stored?.config?.pipeline_enabled, true); + assert.deepEqual(stored?.config?.shadowRouting, { enabled: false }); + assert.deepEqual(stored?.config?.evalRouting, { enabled: false }); + assert.equal(stored?.config?.queueTimeoutMs, 5000); +}); + +test("PUT { config } round-trip rewrites identically", async () => { + const created = await createRoute.POST( + makeCreateRequest( + comboInput("round-trip", { + pipelineConcurrency: 9, + maxComboDepth: 4, + }) + ) + ); + assert.equal(created.status, 201); + const createdBody = (await created.json()) as { id: string; config?: Record }; + assert.equal(createdBody.config?.pipelineConcurrency, undefined); + assert.equal(createdBody.config?.maxComboDepth, 4); + + const response = await comboRoute.PUT(makeUpdateRequest({ config: createdBody.config ?? {} }), { + params: Promise.resolve({ id: createdBody.id }), + }); + assert.equal(response.status, 200); + const stored = (await combosDb.getComboById(createdBody.id)) as { + config?: Record; + } | null; + assert.deepEqual(stored?.config, { maxComboDepth: 4 }); +}); diff --git a/tests/unit/combo-terminal-status-policy-10501.test.ts b/tests/unit/combo-terminal-status-policy-10501.test.ts index f4b8076bc5..08a982acea 100644 --- a/tests/unit/combo-terminal-status-policy-10501.test.ts +++ b/tests/unit/combo-terminal-status-policy-10501.test.ts @@ -54,7 +54,9 @@ function successResponse() { return new Response( JSON.stringify({ id: "chatcmpl-1", - choices: [{ index: 0, message: { role: "assistant", content: "hi there" }, finish_reason: "stop" }], + choices: [ + { index: 0, message: { role: "assistant", content: "hi there" }, finish_reason: "stop" }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ); @@ -86,7 +88,11 @@ test("#10314/#10501: quality failure on target 1 + auth 401 on target 2 → 5xx result.status >= 500, `expected a 5xx terminal status for a heterogeneous quality+auth mix, got ${result.status}` ); - assert.notEqual(result.status, 401, "must not regress to surfacing the sibling target's bare 401"); + assert.notEqual( + result.status, + 401, + "must not regress to surfacing the sibling target's bare 401" + ); const body = (await result.json()) as { error?: { message?: string } }; const message = body.error?.message ?? ""; diff --git a/tests/unit/combo-test-route.test.ts b/tests/unit/combo-test-route.test.ts index 6329499d5e..0053330f23 100644 --- a/tests/unit/combo-test-route.test.ts +++ b/tests/unit/combo-test-route.test.ts @@ -450,6 +450,97 @@ test("combo test route handles upstream timeouts and non-JSON error bodies", asy ); }); +test("combo test route aborts in-flight probes when the client disconnects", async () => { + await createTestCombo(["provider/first", "provider/second"]); + + let inFlight = 0; + let maxInFlight = 0; + let fetchCalls = 0; + let observedCombinedSignal: AbortSignal | null = null; + let observedParentSignal: AbortSignal | null = null; + const realSetTimeout = globalThis.setTimeout; + const realClearTimeout = globalThis.clearTimeout; + let createdProbeTimers = 0; + let clearedProbeTimers = 0; + + const externalController = new AbortController(); + + const setProbeTimeout = ( + handler: (...args: unknown[]) => void, + ms?: number, + ...rest: unknown[] + ) => { + createdProbeTimers += 1; + return realSetTimeout(handler, ms, ...rest); + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.setTimeout = setProbeTimeout as any; + globalThis.clearTimeout = ((id: unknown) => { + clearedProbeTimers += 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return realClearTimeout(id as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + globalThis.fetch = (async (_url, init: RequestInit = {}) => { + fetchCalls += 1; + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + observedCombinedSignal = (init.signal as AbortSignal) ?? null; + observedParentSignal = externalController.signal; + try { + await new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + throw new Error("probe should have been aborted"); + } finally { + inFlight -= 1; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + try { + const pending = route.POST( + new Request("http://localhost/api/combos/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ comboName: "strict-live-test" }), + signal: externalController.signal, + }) + ); + const watchdog = new Promise((_resolve, reject) => { + realSetTimeout(() => reject(new Error("abort did not propagate within 5s")), 5000); + }); + await new Promise((resolve) => realSetTimeout(resolve, 10)); + assert.equal(fetchCalls, 1); + assert.equal(maxInFlight, 1); + externalController.abort(); + + const response = await Promise.race([pending, watchdog]); + const body = (await response.json()) as ComboTestBody; + + assert.equal(response.status, 200); + assert.equal(fetchCalls, 1); + assert.equal(maxInFlight, 1); + assert.equal(inFlight, 0); + assert.equal(observedCombinedSignal?.aborted, true); + assert.equal(observedCombinedSignal !== observedParentSignal, true); + assert.equal(body.resolvedBy, null); + assert.equal(body.results.length, 1); + assert.equal(body.results[0].status, "error"); + assert.equal(body.results[0].error, "Client disconnected"); + assert.equal(clearedProbeTimers >= createdProbeTimers, true); + assert.equal(createdProbeTimers >= 1, true); + } finally { + globalThis.setTimeout = realSetTimeout; + globalThis.clearTimeout = realClearTimeout; + } +}); + test("combo test route stops probing once the total budget is spent", async () => { await createTestCombo(["provider/first", "provider/second", "provider/third"]); diff --git a/tests/unit/combo/combo-dead-config-keys.test.ts b/tests/unit/combo/combo-dead-config-keys.test.ts new file mode 100644 index 0000000000..9847310b24 --- /dev/null +++ b/tests/unit/combo/combo-dead-config-keys.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { DEAD_COMBO_CONFIG_KEYS, stripDeadComboConfigKeys } = await import( + "../../../src/lib/combos/deadConfigKeys.ts" +); + +test("dead keys removed, live keys kept", () => { + const out = stripDeadComboConfigKeys({ + pipelineConcurrency: 4, + resetAwareEnabled: true, + resetAwareWindow: 7, + maxComboDepth: 3, + queueDepth: 10, + queueTimeoutMs: 5000, + }) as Record; + assert.deepEqual(out, { maxComboDepth: 3, queueDepth: 10, queueTimeoutMs: 5000 }); +}); + +test("no dead keys returns original reference", () => { + const input = { maxComboDepth: 3 }; + assert.equal(stripDeadComboConfigKeys(input), input); +}); + +test("null, array, non-object pass through", () => { + assert.equal(stripDeadComboConfigKeys(null), null); + const arr = [1, 2]; + assert.equal(stripDeadComboConfigKeys(arr), arr); + assert.equal(stripDeadComboConfigKeys(42), 42); +}); + +test("exactly 3 dead keys", () => { + assert.deepEqual([...DEAD_COMBO_CONFIG_KEYS], [ + "pipelineConcurrency", + "resetAwareEnabled", + "resetAwareWindow", + ]); +}); + +test("dashboard keeps its UI-only keys out of the shared dead list", () => { + for (const k of ["timeoutMs", "healthCheckEnabled", "healthCheckTimeoutMs"]) { + assert.equal((DEAD_COMBO_CONFIG_KEYS as ReadonlyArray).includes(k), false); + } +}); diff --git a/tests/unit/combo/protected-priority-stop-status-13439.test.ts b/tests/unit/combo/protected-priority-stop-status-13439.test.ts new file mode 100644 index 0000000000..b1fcce82cc --- /dev/null +++ b/tests/unit/combo/protected-priority-stop-status-13439.test.ts @@ -0,0 +1,317 @@ +/** + * #13439 — status of a protected-priority stop (priority strategy, target marked + * fallbackOnlyOnQuotaExhaustion) per stop cause, with PROTECTED_PRIORITY_INFRA_502_ENABLED + * off (default: every stop stays 503, as on the release tip) and on (only provably + * non-quota causes — circuit breaker open, predictive latency skip — answer 502). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-protected-stop-13439-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +const FLAG = "PROTECTED_PRIORITY_INFRA_502_ENABLED"; +delete process.env[FLAG]; + +const { evaluateExecuteTargetGates } = + await import("../../../open-sse/services/combo/executeTargetGates.ts"); +const { executeTargetAttempt } = + await import("../../../open-sse/services/combo/executeTargetAttempt.ts"); +const { getCircuitBreaker, STATE } = await import("../../../src/shared/utils/circuitBreaker.ts"); +const { recordProviderCooldown } = + await import("../../../open-sse/services/providerCooldownTracker.ts"); +const { lockModel, clearAllModelLockouts } = + await import("../../../open-sse/services/accountFallback.ts"); +const { setCredentialHealth, __test_resetCredentialHealthCache } = + await import("../../../src/lib/credentialHealth/cache.ts"); +const semaphore = await import("../../../open-sse/services/accountSemaphore.ts"); +const { recordComboRequest } = await import("../../../open-sse/services/comboMetrics.ts"); +const { createProviderConnection } = await import("../../../src/lib/db/providers.ts"); +const dbCore = await import("../../../src/lib/db/core.ts"); + +import type { + AttemptLoopDeps, + AttemptLoopState, +} from "../../../open-sse/services/combo/attemptLoopTypes.ts"; +import type { ResolvedComboTarget } from "../../../open-sse/services/combo/types.ts"; + +test.afterEach(() => { + delete process.env[FLAG]; + clearAllModelLockouts(); + __test_resetCredentialHealthCache(); + semaphore.resetAll(); +}); + +test.after(() => { + delete process.env[FLAG]; + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +let seq = 0; +const uniqueProvider = (label: string) => `pp13439-${label}-${Date.now()}-${seq++}`; + +function state(target: ResolvedComboTarget, overrides: Partial = {}) { + return { + orderedTargets: [target], + fallbackCount: 0, + recordedAttempts: 0, + comboErrors: [], + lastError: null, + lastStatus: null, + earliestRetryAfter: null, + comboExpired: false, + exhaustedProviders: new Set(), + exhaustedConnections: new Set(), + transientRateLimitedProviders: new Set(), + abortControllers: new Map([[0, new AbortController()]]), + dispatchedTargets: new Set(), + targetFailureTrust: new Map(), + comboAttemptOrder: [], + skippedForCircuitOpen: false, + earliestCircuitOpenRetryMs: 0, + globalAttempts: 0, + observedFailure: false, + allObservedFailuresQuota: true, + observeFailure() {}, + ...overrides, + } as AttemptLoopState; +} + +function deps(overrides: Partial = {}): AttemptLoopDeps { + return { + strategy: "priority", + combo: { name: "pp13439", models: [] }, + config: {}, + log: { info() {}, warn() {}, debug() {}, error() {} }, + settings: null, + resilienceSettings: { + providerCooldown: { enabled: false }, + } as AttemptLoopDeps["resilienceSettings"], + sticky: { targets: [], messageHash: null, stuck: false }, + effectiveSessionId: null, + preScreenMap: new Map(), + quotaCutoffResetWindowConfig: {} as AttemptLoopDeps["quotaCutoffResetWindowConfig"], + maxRetries: 0, + traceInvocationId: "inv-pp13439", + clientRequestedStream: false, + handleSingleModelWithTimeout: async () => { + throw new Error("a protected stop must not dispatch"); + }, + body: { messages: [{ role: "user", content: "hi" }] }, + startTime: Date.now(), + releaseStickyPinOnFailure() {}, + clearStaleLKGP() {}, + ...overrides, + }; +} + +function protectedTarget(provider: string, connectionId = "c1"): ResolvedComboTarget { + return { + kind: "model", + stepId: "s1", + executionKey: `ek-${provider}`, + modelStr: `${provider}/m1`, + provider, + providerId: null, + connectionId, + weight: 1, + label: null, + fallbackOnlyOnQuotaExhaustion: true, + } as ResolvedComboTarget; +} + +type Case = { + name: string; + provablyNonQuota: boolean; + /** Arrange the stop; returns the gate inputs. */ + arrange: () => Promise<{ target: ResolvedComboTarget; st: AttemptLoopState; d: AttemptLoopDeps }>; + message: RegExp; +}; + +const CASES: Case[] = [ + { + name: "circuit breaker open", + provablyNonQuota: true, + message: /circuit breaker is open/, + async arrange() { + const provider = uniqueProvider("cb"); + const cb = getCircuitBreaker(provider, { failureThreshold: 1, resetTimeout: 60_000 }); + cb._onFailure("transient"); + assert.equal(cb.getStatus().state, STATE.OPEN); + const target = protectedTarget(provider); + return { target, st: state(target), d: deps() }; + }, + }, + { + name: "provider cooldown", + provablyNonQuota: false, + message: /is in cooldown/, + async arrange() { + const provider = uniqueProvider("cooldown"); + const d = deps({ + resilienceSettings: { + providerCooldown: { enabled: true }, + } as AttemptLoopDeps["resilienceSettings"], + }); + recordProviderCooldown(provider, "c1", d.resilienceSettings); + const target = protectedTarget(provider); + return { target, st: state(target), d }; + }, + }, + { + name: "request exhaustion (provider)", + provablyNonQuota: false, + message: /is unavailable/, + async arrange() { + const provider = uniqueProvider("exhausted"); + const target = protectedTarget(provider); + return { + target, + st: state(target, { exhaustedProviders: new Set([provider]) }), + d: deps(), + }; + }, + }, + { + name: "request exhaustion (connection)", + provablyNonQuota: false, + message: /is unavailable/, + async arrange() { + const provider = uniqueProvider("conn-exhausted"); + const target = protectedTarget(provider); + return { + target, + st: state(target, { exhaustedConnections: new Set([`${provider}:c1`]) }), + d: deps(), + }; + }, + }, + { + name: "model lockout", + provablyNonQuota: false, + message: /is locked/, + async arrange() { + const provider = uniqueProvider("lock"); + lockModel(provider, "c1", "m1", "quota_exhausted", 60_000); + const target = protectedTarget(provider); + return { target, st: state(target), d: deps() }; + }, + }, + { + name: "model unavailable (no credentials)", + provablyNonQuota: false, + message: /Model .* is unavailable/, + async arrange() { + const target = protectedTarget(uniqueProvider("unavailable")); + return { target, st: state(target), d: deps({ isModelAvailable: async () => false }) }; + }, + }, + { + name: "credential gate", + provablyNonQuota: false, + message: /Credential gate blocked/, + async arrange() { + const provider = uniqueProvider("credgate"); + setCredentialHealth("c-credgate", provider, "error", "probe failed"); + const target = protectedTarget(provider, "c-credgate"); + return { target, st: state(target), d: deps() }; + }, + }, + { + name: "connection concurrency cap", + provablyNonQuota: false, + message: /Connection capacity reached/, + async arrange() { + const provider = uniqueProvider("cap"); + const conn = (await createProviderConnection({ + provider, + authType: "apikey", + name: `cap-${provider}`, + apiKey: "sk-test-13439-cap", + maxConcurrent: 1, + })) as { id: string }; + semaphore.markBlocked( + semaphore.buildAccountSemaphoreKey({ provider, accountKey: conn.id }), + 60_000 + ); + const target = protectedTarget(provider, conn.id); + return { target, st: state(target), d: deps() }; + }, + }, +]; + +for (const c of CASES) { + for (const flagOn of [false, true]) { + const expected = flagOn && c.provablyNonQuota ? 502 : 503; + test(`gate stop "${c.name}" with flag ${flagOn ? "on" : "off"} answers ${expected}`, async () => { + const { st, d } = await c.arrange(); + if (flagOn) process.env[FLAG] = "true"; + const decision = await evaluateExecuteTargetGates({ index: 0, state: st, deps: d }); + assert.equal(decision.kind, "skip"); + assert.ok(decision.kind === "skip" && decision.result && !decision.result.ok); + const response = decision.result.response; + assert.equal(response.status, expected); + const body = (await response.json()) as { error: { message: string } }; + assert.match(body.error.message, c.message); + }); + } +} + +for (const flagOn of [false, true]) { + const expected = flagOn ? 502 : 503; + test(`attempt stop "predictive latency" with flag ${flagOn ? "on" : "off"} answers ${expected}`, async () => { + const provider = uniqueProvider("ttft"); + const target = protectedTarget(provider); + const comboName = `pp13439-ttft-${seq++}`; + for (let i = 0; i < 6; i++) { + recordComboRequest(comboName, target.modelStr, { + success: true, + latencyMs: 9_000, + fallbackCount: 0, + strategy: "priority", + target: { executionKey: target.executionKey, modelStr: target.modelStr, provider }, + } as Parameters[2]); + } + if (flagOn) process.env[FLAG] = "true"; + const result = await executeTargetAttempt({ + index: 0, + state: state(target), + deps: deps({ + combo: { name: comboName, models: [] }, + config: { zeroLatencyOptimizationsEnabled: true, predictiveTtftMs: 1_000 }, + }), + targetForAttempt: target, + profile: {}, + protectedPriorityTarget: true, + }); + assert.ok(result && !result.ok, "predictive latency must stop the protected target"); + assert.equal(result.response.status, expected); + const body = (await result.response.json()) as { error: { message: string } }; + assert.match(body.error.message, /Predictive latency check rejected/); + }); +} + +test("a non-protected target is never stopped (flag on)", async () => { + process.env[FLAG] = "true"; + const provider = uniqueProvider("unprotected"); + const cb = getCircuitBreaker(provider, { failureThreshold: 1, resetTimeout: 60_000 }); + cb._onFailure("transient"); + const target = { ...protectedTarget(provider), fallbackOnlyOnQuotaExhaustion: false }; + const decision = await evaluateExecuteTargetGates({ + index: 0, + state: state(target as ResolvedComboTarget), + deps: deps(), + }); + assert.equal(decision.kind, "skip"); + assert.ok(decision.kind === "skip" && decision.result === null); +}); diff --git a/tests/unit/combo/stale-lkgp-clear-13614.test.ts b/tests/unit/combo/stale-lkgp-clear-13614.test.ts new file mode 100644 index 0000000000..6cb9858e84 --- /dev/null +++ b/tests/unit/combo/stale-lkgp-clear-13614.test.ts @@ -0,0 +1,100 @@ +/** + * #13614 — stale LKGP pin clears on the combo fallback path stay non-blocking, and a + * failed clear is logged with the combo and execution key instead of a bare error. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stale-lkgp-13614-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { clearStaleLKGP } = await import("../../../open-sse/services/combo/staleLkgpClear.ts"); +const combo = await import("../../../open-sse/services/combo.ts"); +const { setLKGP, getLKGP } = await import("../../../src/lib/db/settings.ts"); +const dbCore = await import("../../../src/lib/db/core.ts"); + +test.after(() => { + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function captureWarn() { + const warnings: Array<{ tag: string; msg: string; data: Record }> = []; + return { + warnings, + log: { + warn: (tag: string, msg: string, data?: unknown) => + warnings.push({ tag, msg, data: (data ?? {}) as Record }), + }, + }; +} + +test("combo.ts re-exports the non-blocking clear (single implementation)", () => { + assert.equal(combo.clearStaleLKGP, clearStaleLKGP); +}); + +test("a failed clear resolves and warns with the combo and execution key", async () => { + const { warnings, log } = captureWarn(); + const failure = new Error("database is locked"); + const pending = clearStaleLKGP("combo-a", "ek-7", "combo-id-a", log, "COMBO-RR", async () => { + throw failure; + }); + await assert.doesNotReject(pending); + assert.equal(warnings.length, 1); + assert.equal(warnings[0].tag, "COMBO-RR"); + assert.match(warnings[0].msg, /Failed to clear Last Known Good Provider/); + assert.equal(warnings[0].data.combo, "combo-a"); + assert.equal(warnings[0].data.comboId, "combo-id-a"); + assert.equal(warnings[0].data.executionKey, "ek-7"); + assert.equal(warnings[0].data.err, failure); +}); + +test("a synchronous throw from the writer is caught the same way", async () => { + const { warnings, log } = captureWarn(); + await clearStaleLKGP("combo-b", null, null, log, "COMBO", (() => { + throw new Error("sync boom"); + }) as unknown as (c: string, k: string) => Promise); + assert.equal(warnings.length, 1); + assert.equal(warnings[0].data.executionKey, null); +}); + +test("the call returns before the writes settle (the fallback loop never waits)", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const cleared: string[] = []; + let settled = false; + const pending = clearStaleLKGP("combo-c", "ek-c", "id-c", null, "COMBO", async (_c, key) => { + await gate; + cleared.push(key); + }).then(() => { + settled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false, "clear must still be pending while the caller moves on"); + release(); + await pending; + assert.deepEqual(cleared.sort(), ["ek-c", "id-c"]); +}); + +test("default writer clears both persisted pins in the real DB, no warning", async () => { + await setLKGP("combo-db", "combo-db-id", "openai", "conn-1"); + await setLKGP("combo-db", "ek-db", "openai", "conn-1"); + assert.ok(await getLKGP("combo-db", "combo-db-id")); + const { warnings, log } = captureWarn(); + await clearStaleLKGP("combo-db", "ek-db", "combo-db-id", log, "COMBO"); + assert.equal(await getLKGP("combo-db", "combo-db-id"), null); + assert.equal(await getLKGP("combo-db", "ek-db"), null); + assert.deepEqual(warnings, []); +}); diff --git a/tests/unit/credential-masker-guardrail.test.ts b/tests/unit/credential-masker-guardrail.test.ts index 928b5467e3..177b433fd0 100644 --- a/tests/unit/credential-masker-guardrail.test.ts +++ b/tests/unit/credential-masker-guardrail.test.ts @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; +import safeRegex from "safe-regex"; import { + CREDENTIAL_PATTERNS, CredentialMaskerGuardrail, redactCredentials, } from "../../src/lib/guardrails/credentialMasker.ts"; @@ -91,3 +93,173 @@ test("does not re-redact an already-redacted structured header", async () => { assert.equal(response.headers.Authorization, "Bearer [REDACTED:auth_header]"); }); }); + +// --------------------------------------------------------------------------- +// GHSA-r4q7-7f24-m29p — Groq (`gsk_`), xAI (`xai-`) and OpenAI-compatible +// (`sk-` of any non-48 length: DeepSeek 32-hex, Moonshot/Kimi, Together, …) +// keys had no catalog entry. The runtime guardrail is catalog-only, so every +// one of those shapes passed through `redactCredentials()` untouched; the +// public sanitizer only caught the `sk-` family by coincidence through its +// STRONG_CREDENTIAL_TOKEN fallback and leaked `gsk_`/`xai-` outright. +// +// Key shapes below are deterministic fakes (shape-accurate, never real keys), +// generated the same way as the verifier probe so the regression guard and the +// empirical leak table agree byte-for-byte on what "a key" looks like. +// --------------------------------------------------------------------------- + +const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const HEX = "0123456789abcdef"; + +function fill(n: number, charset: string, seed = 7): string { + let out = ""; + for (let i = 0; i < n; i++) out += charset[(i * 31 + seed * 17 + i * i) % charset.length]; + return out; +} + +// `type` is both the detection name and the `[REDACTED:]` label. +type LeakShape = { label: string; key: string; type: string }; + +const LEAK_SHAPES: LeakShape[] = [ + { label: "groq gsk_ + 52 alnum", key: "gsk_" + fill(52, ALNUM), type: "groq" }, + { label: "xai xai- + 80 alnum", key: "xai-" + fill(80, ALNUM, 3), type: "xai" }, + { label: "deepseek sk- + 32 hex", key: "sk-" + fill(32, HEX), type: "openai_compatible" }, + { + label: "openai-compatible sk- + 40 alnum", + key: "sk-" + fill(40, ALNUM, 9), + type: "openai_compatible", + }, + { + label: "openai-compatible sk- + 51 alnum", + key: "sk-" + fill(51, ALNUM, 11), + type: "openai_compatible", + }, + { + label: "openai-compatible sk- + 20 alnum (minimum bound)", + key: "sk-" + fill(20, ALNUM, 13), + type: "openai_compatible", + }, + { + label: "openai-compatible sk- + 36 mixed [A-Za-z0-9_-]", + key: "sk-" + fill(36, ALNUM + "_-", 2), + type: "openai_compatible", + }, +]; + +const CONTEXTS: Array<[string, (key: string) => string]> = [ + ["bare", (key) => key], + ["sentence", (key) => `upstream error: Invalid API Key ${key} for model foo`], + ["json-msg", (key) => `{"error":{"message":"Incorrect API key provided: ${key}. Check docs."}}`], +]; + +for (const shape of LEAK_SHAPES) { + for (const [contextName, wrap] of CONTEXTS) { + test(`GHSA-r4q7: redacts ${shape.label} in ${contextName} context`, () => { + const input = wrap(shape.key); + const result = redactCredentials(input); + + assert.equal(result.modified, true, `not modified: ${input}`); + assert.equal(result.text.includes(shape.key), false, `key survived: ${result.text}`); + assert.ok( + result.text.includes(`[REDACTED:${shape.type}]`), + `expected [REDACTED:${shape.type}] in: ${result.text}` + ); + assert.deepEqual( + result.detections.map((d) => d.type), + [shape.type], + `unexpected detection set for ${shape.label}` + ); + }); + } +} + +test("GHSA-r4q7: leaves short prose tokens and sub-bound prefixes untouched", () => { + const benign = [ + "gsk_abc", + "xai-1", + "sk-short", + "task sk failed", + "gsk_" + fill(19, ALNUM), + "xai-" + fill(19, ALNUM), + "sk-" + fill(19, ALNUM), + // `sk-` preceded by an alphanumeric is part of a larger word, not a key prefix. + "risk-based-access-control-policy-evaluation-failed", + "Model gpt-5 is not available on this plan", + ]; + + for (const input of benign) { + const result = redactCredentials(input); + assert.equal(result.modified, false, `over-redacted: ${input} -> ${result.text}`); + assert.equal(result.text, input); + assert.deepEqual(result.detections, []); + } +}); + +test("GHSA-r4q7: specific sk- labels still win over the openai_compatible fallback", () => { + const specific: Array<[string, string, string]> = [ + ["sk-proj-" + fill(60, ALNUM + "_-", 4), "openai_proj", "[REDACTED:openai]"], + ["sk-" + fill(48, ALNUM, 21), "openai", "[REDACTED:openai]"], + // `anthropic` only allows one digit after `api`, so the real `api03` shape is + // caught by `anthropic_alt` — same label, pre-existing, out of scope here. + ["sk-ant-api03-" + fill(60, ALNUM + "_-", 6), "anthropic_alt", "[REDACTED:anthropic]"], + ["sk-ant-api3-" + fill(60, ALNUM + "_-", 6), "anthropic", "[REDACTED:anthropic]"], + ["sk-ant-" + fill(40, ALNUM + "_-", 8), "anthropic_alt", "[REDACTED:anthropic]"], + ["sk_live_" + fill(24, ALNUM, 10), "stripe", "[REDACTED:stripe]"], + ]; + + for (const [key, expectedType, expectedLabel] of specific) { + const result = redactCredentials(`upstream error: Invalid API Key ${key} for model foo`); + assert.equal(result.text.includes(key), false, `key survived: ${result.text}`); + assert.ok(result.text.includes(expectedLabel), `expected ${expectedLabel} in ${result.text}`); + assert.equal(result.text.includes("[REDACTED:openai_compatible]"), false, result.text); + assert.deepEqual( + result.detections.map((d) => d.type), + [expectedType], + `fallback must not fire when a specific pattern already matched: ${key}` + ); + } +}); + +test("GHSA-r4q7: catalog ordering keeps the generic sk- fallback last", () => { + const names = CREDENTIAL_PATTERNS.map((p) => p.name); + + assert.equal(names.at(-1), "openai_compatible", "openai_compatible must be the LAST entry"); + assert.equal(new Set(names).size, names.length, "duplicate catalog names"); + + // Every other pattern that can match a string starting with `sk-` must run + // before the fallback, or it would never get to apply its specific label. + const fallbackIndex = names.indexOf("openai_compatible"); + for (const [index, pattern] of CREDENTIAL_PATTERNS.entries()) { + if (pattern.name === "openai_compatible") continue; + if (/^\\?b?sk-/.test(pattern.regex.source)) { + assert.ok(index < fallbackIndex, `${pattern.name} is ordered after openai_compatible`); + } + } + + // The provider-specific entries sit with their siblings, before the loose + // `google` bound and after the last `sk-ant` label. + assert.ok(names.indexOf("groq") > names.indexOf("anthropic_alt")); + assert.ok(names.indexOf("xai") > names.indexOf("anthropic_alt")); + assert.ok(names.indexOf("groq") < names.indexOf("google")); + assert.ok(names.indexOf("xai") < names.indexOf("google")); +}); + +test("GHSA-r4q7: catalog regexes are ReDoS-safe and globally flagged", () => { + for (const pattern of CREDENTIAL_PATTERNS) { + assert.ok(pattern.regex.global, `${pattern.name} must carry the g flag`); + // `auth_header` predates this guard and trips safe-regex's star-height + // heuristic through `\s*` nested inside optional groups; its token class is + // bounded by `{10,}` so it is linear in practice. Everything else, including + // every future addition, must pass. + if (pattern.name === "auth_header") continue; + assert.ok(safeRegex(pattern.regex), `${pattern.name} failed safe-regex: ${pattern.regex}`); + } + + for (const name of ["groq", "xai", "openai_compatible"]) { + const pattern = CREDENTIAL_PATTERNS.find((p) => p.name === name); + assert.ok(pattern, `${name} missing from catalog`); + assert.ok(safeRegex(pattern.regex), `${name} failed safe-regex`); + // Bounded, non-nested charset with a lower length bound only — no `.*`, + // no alternation of overlapping classes. + assert.doesNotMatch(pattern.regex.source, /\.\*|\.\+|\)\*|\)\+/); + } +}); diff --git a/tests/unit/daily-reset-dst-gap.test.ts b/tests/unit/daily-reset-dst-gap.test.ts new file mode 100644 index 0000000000..0ae77e9104 --- /dev/null +++ b/tests/unit/daily-reset-dst-gap.test.ts @@ -0,0 +1,79 @@ +/** + * #13671 — a configured daily reset hour that does not exist on a DST + * spring-forward day must resolve to the first wall-clock time that exists, + * never an hour early (and never on the previous calendar day). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { nextDailyResetAtMs } = await import("../../open-sse/services/dailyQuotaReset.ts"); + +const HOUR_MS = 60 * 60 * 1000; + +function wallClock(timeZone: string, ms: number): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(ms)); +} + +test("New York 02:00 on spring-forward resolves to 03:00 EDT, not 01:00 EST", () => { + // 2026-03-08: 02:00 -> 03:00 in America/New_York; 02:00 does not exist. + const nowMs = Date.parse("2026-03-08T00:30:00-05:00"); + const next = nextDailyResetAtMs("America/New_York", 2, nowMs); + assert.equal(new Date(next).toISOString(), "2026-03-08T07:00:00.000Z"); + assert.equal(wallClock("America/New_York", next), "2026-03-08, 03:00"); +}); + +test("Havana midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => { + // 2026-03-08: 00:00 -> 01:00 in America/Havana; midnight does not exist. + const nowMs = Date.parse("2026-03-07T20:00:00-05:00"); + const next = nextDailyResetAtMs("America/Havana", 0, nowMs); + assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00"); + assert.equal(next - nowMs, 4 * HOUR_MS); +}); + +test("Santiago midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => { + // 2026-09-06: 00:00 -> 01:00 in America/Santiago; midnight does not exist. + const nowMs = Date.parse("2026-09-05T20:00:00-04:00"); + const next = nextDailyResetAtMs("America/Santiago", 0, nowMs); + assert.equal(wallClock("America/Santiago", next), "2026-09-06, 01:00"); + assert.equal(next - nowMs, 4 * HOUR_MS); +}); + +test("between the old (wrong) 23:00 and the real 01:00 the reset is still ahead", () => { + const nowMs = Date.parse("2026-03-07T23:30:00-05:00"); // Havana 23:30, before the gap + const next = nextDailyResetAtMs("America/Havana", 0, nowMs); + assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00"); + assert.equal(next - nowMs, 30 * 60 * 1000); +}); + +test("fold hour keeps the first occurrence (characterization)", () => { + // 2026-11-01: fall back, 01:00 occurs twice; the first (EDT) occurrence wins. + const nowMs = Date.parse("2026-11-01T00:30:00-04:00"); + const next = nextDailyResetAtMs("America/New_York", 1, nowMs); + assert.equal(new Date(next).toISOString(), "2026-11-01T05:00:00.000Z"); +}); + +test("a 25h fall-back day keeps its real 24.5h magnitude (characterization, no clamp)", () => { + const nowMs = Date.parse("2026-11-01T00:30:00-04:00"); + const next = nextDailyResetAtMs("America/New_York", 0, nowMs); + assert.equal(next - nowMs, 24.5 * HOUR_MS); +}); + +test("ordinary days are unchanged", () => { + const nowMs = Date.parse("2026-01-15T10:00:00Z"); + assert.equal( + new Date(nextDailyResetAtMs("Europe/Paris", 0, nowMs)).toISOString(), + "2026-01-15T23:00:00.000Z" + ); + assert.equal( + new Date(nextDailyResetAtMs("Asia/Kolkata", 0, nowMs)).toISOString(), + "2026-01-15T18:30:00.000Z" + ); +}); diff --git a/tests/unit/daily-reset-tz-threading.test.ts b/tests/unit/daily-reset-tz-threading.test.ts new file mode 100644 index 0000000000..42979e3d9d --- /dev/null +++ b/tests/unit/daily-reset-tz-threading.test.ts @@ -0,0 +1,248 @@ +/** + * #13440 — non-TPD daily-quota cooldowns honor the provider node's configured + * daily-reset clock (dailyQuotaResetTimezone + dailyQuotaResetHour) instead of + * host midnight, on both the single-model classifier and the combo call sites. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.TZ = "UTC"; +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-daily-reset-13440-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { checkFallbackError, getMsUntilTomorrow } = + await import("../../open-sse/services/accountFallback.ts"); +const { nextDailyResetAtMs } = await import("../../open-sse/services/dailyQuotaReset.ts"); +const { resolveComboDailyReset } = + await import("../../open-sse/services/combo/comboDailyResetClock.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const rrState = await import("../../open-sse/services/combo/rrState.ts"); +const { createProviderNode, updateProviderNode } = + await import("../../src/lib/db/providers/nodes.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); + +const DAILY_TEXT = "daily quota exceeded, try again tomorrow"; +const HOUR_MS = 3_600_000; +const realDateNow = Date.now; + +/** Shift the wall clock so "now" is `nowMs` (keeps advancing in real time). */ +function shiftClockTo(nowMs: number): void { + const offset = nowMs - realDateNow(); + Date.now = () => realDateNow() + offset; +} + +function dailyCooldownMs(timezone: unknown, hour: unknown, nowMs: number): number { + return checkFallbackError(403, DAILY_TEXT, 0, null, "tz-thread-prov", null, null, null, null, { + timezone, + hour, + nowMs, + }).cooldownMs; +} + +type LogCall = { level: string; msg: string }; +function captureLog(calls: LogCall[]) { + const push = (level: string) => (_tag: string, msg: unknown) => + calls.push({ level, msg: String(msg) }); + return { info: push("info"), warn: push("warn"), debug: push("debug"), error: push("error") }; +} + +function dailyQuotaResponse(status: number): Response { + return new Response(JSON.stringify({ error: { message: DAILY_TEXT } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function dispatch( + combo: Record, + failingProvider: string, + failStatus: number, + calls: LogCall[] +) { + const res = await handleComboChat({ + body: { model: combo.name, messages: [{ role: "user", content: "hi" }], stream: false }, + combo, + allCombos: [combo], + isModelAvailable: async () => true, + relayOptions: undefined, + signal: undefined, + settings: {}, + log: captureLog(calls), + handleSingleModel: async (_b: unknown, modelStr: string) => { + if (modelStr.startsWith(`${failingProvider}/`)) return dailyQuotaResponse(failStatus); + return Response.json({ choices: [{ message: { role: "assistant", content: "ok" } }] }); + }, + }); + await (res as Response | undefined)?.body?.cancel().catch(() => {}); +} + +function comboFor(name: string, strategy: string, nodeId: string, config = {}) { + return { + name, + strategy, + config: { maxRetries: 0, disableSessionStickiness: true, ...config }, + models: [ + { kind: "model", provider: nodeId, providerId: nodeId, model: "m-a", id: `${name}-0` }, + { kind: "model", provider: "glm-cn", providerId: "glm-cn", model: "m-b", id: `${name}-1` }, + ], + }; +} + +function rrCooldownFromLogs(calls: LogCall[]): number | null { + for (const c of calls) { + const m = /error 429, cooldown (\d+)ms/.exec(c.msg); + if (c.level === "warn" && m) return Number(m[1]); + } + return null; +} + +test.beforeEach(() => { + rrState.rrCounters.clear(); + rrState.rrStickyTargets.clear(); +}); + +test.afterEach(() => { + Date.now = realDateNow; +}); + +test.after(() => { + Date.now = realDateNow; + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("checkFallbackError: Paris pre-spring-forward resolves to provider midnight", () => { + const nowMs = Date.parse("2026-03-28T21:00:00Z"); + assert.equal(dailyCooldownMs("Europe/Paris", 0, nowMs), 2 * HOUR_MS); +}); + +test("checkFallbackError: Paris pre-fall-back resolves to provider midnight", () => { + const nowMs = Date.parse("2026-10-24T10:00:00Z"); + assert.equal(dailyCooldownMs("Europe/Paris", 0, nowMs), 12 * HOUR_MS); +}); + +test("checkFallbackError: New York resolves to provider midnight, not host midnight", () => { + const nowMs = Date.parse("2026-01-16T04:00:00Z"); + assert.equal(dailyCooldownMs("America/New_York", 0, nowMs), HOUR_MS); +}); + +// The legacy value is recomputed from a live Date.now() inside getMsUntilTomorrow(), so the two +// reads are a few ms apart under load; compare within a second instead of strictly. +function assertWithinASecond(actual: number, expected: number, label: string): void { + assert.ok( + Math.abs(actual - expected) <= 1000, + `${label}: expected ${actual} within 1s of ${expected}` + ); +} + +test("checkFallbackError: unconfigured clock keeps the legacy host-midnight value", () => { + shiftClockTo(Date.parse("2026-01-15T12:00:00Z")); + assertWithinASecond( + dailyCooldownMs(undefined, undefined, Date.now()), + getMsUntilTomorrow(), + "unconfigured clock" + ); +}); + +test("checkFallbackError: invalid timezone falls back to legacy without throwing", () => { + shiftClockTo(Date.parse("2026-01-15T12:00:00Z")); + assertWithinASecond( + dailyCooldownMs("Mars/Olympus", 0, Date.now()), + getMsUntilTomorrow(), + "invalid tz" + ); +}); + +test("resolveComboDailyReset: matches id and prefix, null for unknown providers", async () => { + const node = await createProviderNode({ + type: "openai-compatible", + name: "Daily reset lookup", + prefix: "drlookup13440", + apiType: "chat", + baseUrl: "http://127.0.0.1:9/v1", + dailyQuotaResetTimezone: "Europe/Paris", + dailyQuotaResetHour: 7, + }); + const expected = { timezone: "Europe/Paris", hour: 7 }; + assert.deepEqual(await resolveComboDailyReset(String(node.id)), expected); + assert.deepEqual(await resolveComboDailyReset("drlookup13440"), expected); + assert.equal(await resolveComboDailyReset("no-such-provider-13440"), null); + assert.equal(await resolveComboDailyReset("unknown"), null); + assert.equal(await resolveComboDailyReset(null), null); +}); + +test("round-robin combo passes the node clock, and a timezone edit applies without restart", async () => { + const node = await createProviderNode({ + type: "openai-compatible", + name: "Daily reset RR", + prefix: "drrr13440", + apiType: "chat", + baseUrl: "http://127.0.0.1:9/v1", + dailyQuotaResetTimezone: "America/New_York", + dailyQuotaResetHour: 0, + }); + const nodeId = String(node.id); + + // 1h before New York midnight: provider clock says 1h, host (UTC) midnight is ~19-20h away. + shiftClockTo(nextDailyResetAtMs("America/New_York", 0, realDateNow()) - HOUR_MS); + assert.ok(getMsUntilTomorrow() > 3 * HOUR_MS, "fixture must separate host and provider clocks"); + const first: LogCall[] = []; + await dispatch(comboFor("rr13440-a", "round-robin", nodeId), nodeId, 429, first); + const firstCooldown = rrCooldownFromLogs(first); + assert.ok(firstCooldown !== null, "RR must log the semaphore cooldown for the 429"); + assert.ok( + Math.abs(firstCooldown - HOUR_MS) < 10_000, + `expected ~1h (New York midnight), got ${firstCooldown}ms` + ); + + // Operator edits the node: Tokyo midnight. No restart, no cache reset in the test. + await updateProviderNode(nodeId, { dailyQuotaResetTimezone: "Asia/Tokyo" }); + shiftClockTo(nextDailyResetAtMs("Asia/Tokyo", 0, realDateNow()) - 2 * HOUR_MS); + assert.ok(Math.abs(getMsUntilTomorrow() - 2 * HOUR_MS) > HOUR_MS); + const second: LogCall[] = []; + await dispatch(comboFor("rr13440-b", "round-robin", nodeId), nodeId, 429, second); + const secondCooldown = rrCooldownFromLogs(second); + assert.ok(secondCooldown !== null, "RR must log the semaphore cooldown for the 429"); + assert.ok( + Math.abs(secondCooldown - 2 * HOUR_MS) < 10_000, + `expected ~2h (Tokyo midnight after the edit), got ${secondCooldown}ms` + ); +}); + +test("priority combo attempt path passes the node clock to checkFallbackError", async () => { + const node = await createProviderNode({ + type: "openai-compatible", + name: "Daily reset priority", + prefix: "drprio13440", + apiType: "chat", + baseUrl: "http://127.0.0.1:9/v1", + dailyQuotaResetTimezone: "America/New_York", + dailyQuotaResetHour: 0, + }); + const nodeId = String(node.id); + + // 2s before New York midnight: the provider-clock cooldown (~2s) is short enough for + // the pre-fallback wait (<= MAX_FALLBACK_WAIT_MS); host midnight (hours) is not. + shiftClockTo(nextDailyResetAtMs("America/New_York", 0, realDateNow()) - 2_000); + const calls: LogCall[] = []; + await dispatch( + comboFor("prio13440", "priority", nodeId, { fallbackDelayMs: 25 }), + nodeId, + 503, + calls + ); + assert.ok( + calls.some((c) => c.level === "debug" && /Waiting 25ms before fallback/.test(c.msg)), + `expected the provider-clock fallback wait; logs: ${JSON.stringify(calls.map((c) => c.msg))}` + ); +}); diff --git a/tests/unit/db-call-log-stats-3500.test.ts b/tests/unit/db-call-log-stats-3500.test.ts index b4d7b48ae0..907b9c9306 100644 --- a/tests/unit/db-call-log-stats-3500.test.ts +++ b/tests/unit/db-call-log-stats-3500.test.ts @@ -81,6 +81,21 @@ function insertCallLog(row: Record) { test.before(() => { core.resetDbInstance(); + // Search queries only surface providers with a live provider_connections + // row — seed connections for the search providers used below so their + // call_logs rows are not filtered out as deleted providers. + const now = new Date().toISOString(); + const db = core.getDbInstance(); + for (const [id, provider] of [ + ["conn-3500-brave", "brave"], + ["conn-3500-serper", "serper"], + ["conn-3500-bing", "bing"], + ["conn-3500-rare-provider", "rare_provider"], + ] as const) { + db.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run(id, provider, now, now); + } }); test.after(() => { @@ -295,12 +310,12 @@ test("#3500 getSearchProviderCounts — ordered by cnt desc", () => { if (rows.length >= 2) { assert.ok(rows[0].cnt >= rows[rows.length - 1].cnt, "ordered by cnt desc"); } - // bing (5 added) should beat rare_provider (2 added) if both appear + // bing (5 added) should beat rare_provider (2 added) const bing = rows.find((r) => r.provider === "bing"); const rare = rows.find((r) => r.provider === "rare_provider"); - if (bing && rare) { - assert.ok(bing.cnt > rare.cnt, "bing cnt > rare_provider cnt"); - } + assert.ok(bing, "bing row present"); + assert.ok(rare, "rare_provider row present"); + assert.ok(bing.cnt > rare.cnt, "bing cnt > rare_provider cnt"); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/error-sensitive-redaction.test.ts b/tests/unit/error-sensitive-redaction.test.ts index eb2e7e2392..187f9fa414 100644 --- a/tests/unit/error-sensitive-redaction.test.ts +++ b/tests/unit/error-sensitive-redaction.test.ts @@ -103,6 +103,10 @@ test("sanitizeErrorMessage covers the canonical credential pattern catalog", () `key-${"a".repeat(32)}`, `M${"A".repeat(23)}.${"B".repeat(6)}.${"C".repeat(27)}`, "postgresql://db-user:db-password@db.internal.example/app", + // GHSA-r4q7-7f24-m29p — Groq and xAI keys had no catalog entry and no + // STRONG_CREDENTIAL_TOKEN fallback, so they reached error bodies verbatim. + `gsk_${"A".repeat(52)}`, + `xai-${"A".repeat(80)}`, ]; for (const credential of credentials) { diff --git a/tests/unit/estimated-usage-billing-guard.test.ts b/tests/unit/estimated-usage-billing-guard.test.ts new file mode 100644 index 0000000000..193843772c --- /dev/null +++ b/tests/unit/estimated-usage-billing-guard.test.ts @@ -0,0 +1,199 @@ +// Estimated token usage: billing stays exactly as it is, and the call log records that the +// counts were estimated. Drives the real handleChatCore (non-streaming and streaming) with a +// fetch stub, then reads the persisted call log and the API-key spend ledger. +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-estimated-usage-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); +const { getDailyTotal } = await import("../../src/domain/costRules.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +const { extractUsage, filterUsageForFormat, isEstimatedUsage } = + await import("../../open-sse/utils/usageTracking.ts"); +const { extractUsageFromResponse } = await import("../../open-sse/handlers/usageExtractor.ts"); + +const originalFetch = globalThis.fetch; +const silentLog = { debug() {}, info() {}, warn() {}, error() {} }; +const MODEL = "gpt-4o-mini"; + +before(() => { + core.resetDbInstance(); +}); + +after(async () => { + globalThis.fetch = originalFetch; + await callLogs.closeCallLogSaves(5_000); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const USAGE = { prompt_tokens: 1200, completion_tokens: 800, total_tokens: 2000 }; + +function jsonCompletion(usage: Record): Response { + return new Response( + JSON.stringify({ + id: "chatcmpl-estimated", + object: "chat.completion", + model: MODEL, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +function sseCompletion(events: unknown[]): Response { + const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +const textChunk = (content: string, finish: string | null = null) => ({ + id: "chatcmpl-estimated", + object: "chat.completion.chunk", + model: MODEL, + choices: [{ index: 0, delta: { content }, finish_reason: finish }], +}); + +async function runChat(apiKeyId: string, stream: boolean, response: () => Response) { + globalThis.fetch = (async () => response()) as typeof fetch; + const body = { model: MODEL, stream, messages: [{ role: "user", content: "hello there" }] }; + const result = (await handleChatCore({ + body, + modelInfo: { provider: "openai", model: MODEL, extendedContext: false }, + credentials: { apiKey: "sk-test-estimated" }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body, + headers: new Headers({ accept: stream ? "text/event-stream" : "application/json" }), + }, + apiKeyInfo: { id: apiKeyId, name: apiKeyId }, + userAgent: "unit-test", + isCombo: false, + log: silentLog, + } as unknown as Parameters[0])) as { response?: Response }; + const clientText = result.response ? await result.response.text() : ""; + return clientText; +} + +async function persistedLog(apiKeyId: string) { + const deadline = Date.now() + 15_000; + for (;;) { + await callLogs.waitForCallLogSaves(5_000); + const rows = (await callLogs.getCallLogs({})) as Array<{ id: string; apiKeyId: string }>; + const row = rows.find((r) => r.apiKeyId === apiKeyId); + if (row) return callLogs.getCallLogById(row.id); + if (Date.now() > deadline) throw new Error(`no call log for ${apiKeyId}`); + await new Promise((r) => setTimeout(r, 50)); + } +} + +async function spend(apiKeyId: string): Promise { + const deadline = Date.now() + 5_000; + let total = getDailyTotal(apiKeyId); + while (total === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + total = getDailyTotal(apiKeyId); + } + return total; +} + +function usageEstimatedMeta(entry: unknown): unknown { + const responseBody = (entry as { responseBody?: { _omniroute?: Record } }) + ?.responseBody; + return responseBody?._omniroute?.usageEstimated; +} + +test("extraction keeps an internal estimated marker that never serializes or spreads", () => { + const estimatedChunk = { choices: [], usage: { ...USAGE, estimated: true } }; + const reportedChunk = { choices: [], usage: { ...USAGE } }; + const estimated = extractUsage(estimatedChunk); + const reported = extractUsage(reportedChunk); + assert.equal(isEstimatedUsage(estimated), true); + assert.equal(isEstimatedUsage(reported), false); + assert.deepStrictEqual(estimated, reported, "token fields are untouched"); + assert.equal(JSON.stringify(estimated), JSON.stringify(reported)); + assert.equal(isEstimatedUsage({ ...estimated }), false, "spread copies never carry it"); + assert.equal(isEstimatedUsage(filterUsageForFormat(estimated, "openai")), false); + + const fromResponse = extractUsageFromResponse({ usage: { ...USAGE, estimated: true } }, "x"); + assert.equal(isEstimatedUsage(fromResponse), true); + assert.equal(JSON.stringify(fromResponse).includes("estimated"), false); + assert.equal(isEstimatedUsage(extractUsageFromResponse({ usage: { ...USAGE } }, "x")), false); +}); + +test("non-streaming estimated usage is still billed and is marked in the call log", async () => { + const clientText = await runChat("key-json-estimated", false, () => + jsonCompletion({ ...USAGE, estimated: true }) + ); + assert.ok((await spend("key-json-estimated")) > 0, "API-key spend still records the cost"); + const entry = await persistedLog("key-json-estimated"); + assert.equal(entry?.tokens?.in, USAGE.prompt_tokens); + assert.equal(entry?.tokens?.out, USAGE.completion_tokens); + assert.equal(usageEstimatedMeta(entry), true); + assert.doesNotMatch(clientText, /usageEstimated/); +}); + +test("non-streaming provider-reported usage carries no estimated marker", async () => { + await runChat("key-json-reported", false, () => jsonCompletion({ ...USAGE })); + assert.ok((await spend("key-json-reported")) > 0); + const entry = await persistedLog("key-json-reported"); + assert.equal(usageEstimatedMeta(entry), undefined); +}); + +test("a stream without upstream usage is billed on the estimate and marked in the call log", async () => { + const clientText = await runChat("key-sse-silent", true, () => + sseCompletion([textChunk("hello from the model"), textChunk("", "stop")]) + ); + assert.match(clientText, /hello from the model/); + assert.ok((await spend("key-sse-silent")) > 0, "API-key spend still records the estimate"); + const entry = await persistedLog("key-sse-silent"); + assert.ok((entry?.tokens?.out ?? 0) > 0); + assert.equal(usageEstimatedMeta(entry), true); + assert.doesNotMatch(clientText, /usageEstimated/); +}); + +test("a stream whose executor reports estimated usage is billed and marked in the call log", async () => { + const clientText = await runChat("key-sse-executor", true, () => + sseCompletion([ + textChunk("hello from the model"), + textChunk("", "stop"), + { + id: "chatcmpl-estimated", + object: "chat.completion.chunk", + model: MODEL, + choices: [], + usage: { ...USAGE, estimated: true }, + }, + ]) + ); + assert.ok((await spend("key-sse-executor")) > 0); + const entry = await persistedLog("key-sse-executor"); + assert.equal(entry?.tokens?.in, USAGE.prompt_tokens); + assert.equal(usageEstimatedMeta(entry), true); + assert.doesNotMatch(clientText, /usageEstimated/); +}); + +test("a stream with provider-reported usage carries no estimated marker", async () => { + await runChat("key-sse-reported", true, () => + sseCompletion([ + textChunk("hello from the model"), + textChunk("", "stop"), + { + id: "chatcmpl-estimated", + object: "chat.completion.chunk", + model: MODEL, + choices: [], + usage: { ...USAGE }, + }, + ]) + ); + const entry = await persistedLog("key-sse-reported"); + assert.equal(entry?.tokens?.in, USAGE.prompt_tokens); + assert.equal(usageEstimatedMeta(entry), undefined); +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 9f0184f41c..d6486a0624 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -39,7 +39,8 @@ const { // OMNIROUTE_DISABLE_THINKING_LEVEL_VARIANTS bumped it from 53 to 54; // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. -const EXPECTED_FEATURE_FLAG_COUNT = 55; +// #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. +const EXPECTED_FEATURE_FLAG_COUNT = 60; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -150,6 +151,21 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(early.requiresRestart, false); assert.strictEqual(early.warningLevel, "caution"); + const orderFix = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "STREAM_RECOVERY_TOOLCALL_ORDER_FIX" + ); + + assert.ok(orderFix, "STREAM_RECOVERY_TOOLCALL_ORDER_FIX should exist"); + assert.strictEqual(orderFix.category, "runtime"); + assert.strictEqual(orderFix.type, "boolean"); + assert.strictEqual(orderFix.defaultValue, "false"); + assert.strictEqual(orderFix.requiresRestart, false); + assert.strictEqual(orderFix.warningLevel, "info"); + assert.strictEqual( + orderFix.descriptionI18nKey, + "featureFlagStreamRecoveryToolcallOrderFixDescription" + ); + assert.ok(midstream, "STREAM_RECOVERY_MIDSTREAM_ENABLED should exist"); assert.strictEqual(midstream.category, "runtime"); assert.strictEqual(midstream.type, "boolean"); diff --git a/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts b/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts new file mode 100644 index 0000000000..c5f4d183ff --- /dev/null +++ b/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts @@ -0,0 +1,490 @@ +/** + * GHSA-2jm2-mpx8-6523 + GHSA-m3hp-hq9g-fpmv — route-level regression guard for the + * `/api/v1/files` and `/api/v1/batches` ownership model. + * + * Both advisories share one root cause: `getApiKeyRequestScope` resolves three + * different callers to the SAME `{ apiKeyId: null, isSessionAuth: false }` shape — + * an anonymous request, a request presenting an invalid/rotated bearer, and (with + * `isSessionAuth: true`) the operator's dashboard session — and the routes then + * treated "no key" as "no restriction": + * + * - the list routes coerced `apiKeyId || undefined`, which the DB layer reads as + * "instance-wide" — every tenant's file and batch metadata to an anonymous + * caller (GHSA-m3hp); + * - the single-item routes short-circuited to ALLOW when the record's own + * `api_key_id` was null, so a null-owner file (dashboard upload, anonymous + * upload, batch output inheriting a null owner) was readable, downloadable and + * deletable by anybody, and a foreign key could run a batch over it (GHSA-2jm2). + * + * The fix is one shared 3-way rule (`canAccessOwnedRecord` in + * `_helpers/apiKeyScope.ts`): a dashboard session is the instance operator and may + * act on any record; an API key may act on its own records only; a null-owner + * record is unattributable and is denied to every non-session caller. The list + * routes apply the same explicit 3-way scope as `delete-completed` and fail closed + * with a `buildErrorBody()` 401 when the caller is neither a key nor a session. + * + * Modelled on tests/unit/batches-delete-completed-route-scope.test.ts: drives the + * REAL route handlers with REAL credentials (API keys via `createApiKey`, a dashboard + * session via a signed `auth_token` cookie). Self-isolating: DATA_DIR points at a + * fresh temp dir BEFORE any `@/lib/db/*` module loads, so this file never touches + * ~/.omniroute. + */ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ownership-2jm2-m3hp-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "ownership-2jm2-api-secret"; +process.env.JWT_SECRET = "ownership-2jm2-jwt-secret"; + +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); +const { createFile, getFile, getFileContent } = await import("../../src/lib/db/files.ts"); +const { createBatch, getBatch } = await import("../../src/lib/db/batches.ts"); +const { canAccessOwnedRecord } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); +const filesRoute = await import("../../src/app/api/v1/files/route.ts"); +const fileByIdRoute = await import("../../src/app/api/v1/files/[id]/route.ts"); +const fileContentRoute = await import("../../src/app/api/v1/files/[id]/content/route.ts"); +const batchesRoute = await import("../../src/app/api/v1/batches/route.ts"); +const batchByIdRoute = await import("../../src/app/api/v1/batches/[id]/route.ts"); +const batchCancelRoute = await import("../../src/app/api/v1/batches/[id]/cancel/route.ts"); + +type Headers = Record; +type ErrorBody = { error?: { message: string; type?: string; code?: string } }; +type ListBody = ErrorBody & { object?: string; data?: Array<{ id: string }>; total_count?: number }; + +async function sessionCookie(): Promise { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const jwt = await new SignJWT({ authenticated: true, sub: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + return `auth_token=${jwt}`; +} + +function seedFile(apiKeyId: string | null, label: string) { + return createFile({ + bytes: label.length, + filename: `${label}.jsonl`, + purpose: "batch", + content: Buffer.from(label), + mimeType: "application/jsonl", + apiKeyId, + }); +} + +function seedBatch( + apiKeyId: string | null, + label: string, + status: "validating" | "completed" = "validating" +) { + const file = seedFile(apiKeyId, label); + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + status, + apiKeyId, + }); + return { file, batch }; +} + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +async function listFilesVia(headers: Headers) { + const res = await filesRoute.GET( + new Request("http://localhost/api/v1/files?limit=100", { headers }) + ); + return { res, body: (await res.json()) as ListBody }; +} + +async function listBatchesVia(headers: Headers) { + const res = await batchesRoute.GET( + new Request("http://localhost/api/v1/batches?limit=100", { headers }) + ); + return { res, body: (await res.json()) as ListBody }; +} + +async function getFileVia(headers: Headers, id: string) { + return fileByIdRoute.GET( + new Request(`http://localhost/api/v1/files/${id}`, { headers }), + params(id) + ); +} + +async function getFileContentVia(headers: Headers, id: string) { + return fileContentRoute.GET( + new Request(`http://localhost/api/v1/files/${id}/content`, { headers }), + params(id) + ); +} + +async function deleteFileVia(headers: Headers, id: string) { + return fileByIdRoute.DELETE( + new Request(`http://localhost/api/v1/files/${id}`, { method: "DELETE", headers }), + params(id) + ); +} + +async function getBatchVia(headers: Headers, id: string) { + return batchByIdRoute.GET( + new Request(`http://localhost/api/v1/batches/${id}`, { headers }), + params(id) + ); +} + +async function deleteBatchVia(headers: Headers, id: string) { + return batchByIdRoute.DELETE( + new Request(`http://localhost/api/v1/batches/${id}`, { method: "DELETE", headers }), + params(id) + ); +} + +async function cancelBatchVia(headers: Headers, id: string) { + return batchCancelRoute.POST( + new Request(`http://localhost/api/v1/batches/${id}/cancel`, { method: "POST", headers }), + params(id) + ); +} + +async function createBatchVia(headers: Headers, inputFileId: string) { + const res = await batchesRoute.POST( + new Request("http://localhost/api/v1/batches", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ + input_file_id: inputFileId, + endpoint: "/v1/chat/completions", + completion_window: "24h", + }), + }) + ); + return { res, body: (await res.json()) as ErrorBody & { id?: string } }; +} + +function assertAuthRequired401(res: Response, body: ErrorBody, label: string) { + assert.strictEqual(res.status, 401, `${label}: anonymous caller must be rejected`); + assert.strictEqual(body.error?.message, "Authentication required", label); + assert.strictEqual(body.error?.type, "authentication_error", `${label}: buildErrorBody() shape`); + assert.strictEqual(body.error?.code, "invalid_api_key", label); + assert.ok(!body.error?.message.includes("at /"), `${label}: stack trace leaked`); +} + +function assertInvalidKey401(res: Response, body: ErrorBody, label: string) { + assert.strictEqual(res.status, 401, `${label}: an unresolvable bearer must fail closed`); + assert.strictEqual(body.error?.message, "Invalid API key", label); + assert.strictEqual(body.error?.type, "authentication_error", `${label}: buildErrorBody() shape`); + assert.ok(!body.error?.message.includes("at /"), `${label}: stack trace leaked`); +} + +describe("canAccessOwnedRecord — the shared 3-way ownership rule", () => { + it("a dashboard session may act on any record, owned or not", () => { + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, "key-1"), + true + ); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: "k" }, "key-1"), true); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, null), true); + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, undefined), + true + ); + }); + + it("a null-owner record is denied to every non-session caller — anonymous AND any key", () => { + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: null }, null), false); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k" }, null), false); + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: null }, undefined), + false + ); + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k" }, undefined), + false + ); + }); + + it("a key may act on its own records only", () => { + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k1" }, "k1"), true); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k2" }, "k1"), false); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: null }, "k1"), false); + }); +}); + +after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +describe("GET /api/v1/files + GET /api/v1/batches — caller scope (GHSA-m3hp-hq9g-fpmv)", () => { + it("(a) no credential at all → 401 on both lists, nothing enumerated", async () => { + const keyA = await createApiKey("m3hp-a-key", "machine-m3hp-a", []); + seedBatch(keyA.id, "m3hp-a-victim"); + + const files = await listFilesVia({}); + assertAuthRequired401(files.res, files.body, "GET /v1/files"); + assert.strictEqual(files.body.data, undefined, "no file rows in a 401 body"); + + const batches = await listBatchesVia({}); + assertAuthRequired401(batches.res, batches.body, "GET /v1/batches"); + assert.strictEqual(batches.body.data, undefined, "no batch rows in a 401 body"); + }); + + it("(b) an invalid/rotated bearer → 401 on both lists — even alongside a session cookie", async () => { + const keyA = await createApiKey("m3hp-b-key", "machine-m3hp-b", []); + seedBatch(keyA.id, "m3hp-b-victim"); + const bogus = { Authorization: "Bearer sk-omni-this-key-was-rotated-away-m3hp" }; + + const files = await listFilesVia(bogus); + assertInvalidKey401(files.res, files.body, "GET /v1/files"); + const batches = await listBatchesVia(bogus); + assertInvalidKey401(batches.res, batches.body, "GET /v1/batches"); + + const withSession = { ...bogus, cookie: await sessionCookie() }; + const files2 = await listFilesVia(withSession); + assertInvalidKey401(files2.res, files2.body, "GET /v1/files + session cookie"); + const batches2 = await listBatchesVia(withSession); + assertInvalidKey401(batches2.res, batches2.body, "GET /v1/batches + session cookie"); + }); + + it("(c) key A lists only A's rows — B's and null-owner rows never appear", async () => { + const keyA = await createApiKey("m3hp-c-key-a", "machine-m3hp-ca", []); + const keyB = await createApiKey("m3hp-c-key-b", "machine-m3hp-cb", []); + const own = seedBatch(keyA.id, "m3hp-c-own"); + const other = seedBatch(keyB.id, "m3hp-c-other"); + const unowned = seedBatch(null, "m3hp-c-unowned"); + const headers = { Authorization: `Bearer ${keyA.key}` }; + + const files = await listFilesVia(headers); + assert.strictEqual(files.res.status, 200); + const fileIds = new Set(files.body.data!.map((f) => f.id)); + assert.ok(fileIds.has(own.file.id), "key A sees its own file"); + assert.ok(!fileIds.has(other.file.id), "key B's file must not leak to key A"); + assert.ok(!fileIds.has(unowned.file.id), "the null-owner file must not leak to key A"); + assert.strictEqual(files.body.total_count, files.body.data!.length); + assert.ok(files.body.data!.every((f) => getFile(f.id)?.apiKeyId === keyA.id)); + + const batches = await listBatchesVia(headers); + assert.strictEqual(batches.res.status, 200); + const batchIds = new Set(batches.body.data!.map((b) => b.id)); + assert.ok(batchIds.has(own.batch.id), "key A sees its own batch"); + assert.ok(!batchIds.has(other.batch.id), "key B's batch must not leak to key A"); + assert.ok(!batchIds.has(unowned.batch.id), "the null-owner batch must not leak to key A"); + assert.strictEqual(batches.body.total_count, batches.body.data!.length); + assert.ok(batches.body.data!.every((b) => getBatch(b.id)?.apiKeyId === keyA.id)); + }); + + it("(d) a dashboard session WITHOUT a key lists the whole instance", async () => { + const keyA = await createApiKey("m3hp-d-key-a", "machine-m3hp-da", []); + const keyB = await createApiKey("m3hp-d-key-b", "machine-m3hp-db", []); + const a = seedBatch(keyA.id, "m3hp-d-a"); + const b = seedBatch(keyB.id, "m3hp-d-b"); + const unowned = seedBatch(null, "m3hp-d-unowned"); + const headers = { cookie: await sessionCookie() }; + + const files = await listFilesVia(headers); + assert.strictEqual(files.res.status, 200); + const fileIds = new Set(files.body.data!.map((f) => f.id)); + for (const f of [a.file, b.file, unowned.file]) { + assert.ok(fileIds.has(f.id), `session sees ${f.filename}`); + } + + const batches = await listBatchesVia(headers); + assert.strictEqual(batches.res.status, 200); + const batchIds = new Set(batches.body.data!.map((x) => x.id)); + for (const x of [a.batch, b.batch, unowned.batch]) { + assert.ok(batchIds.has(x.id), `session sees batch ${x.id}`); + } + }); + + it("(e) a request carrying BOTH a session cookie and key A stays scoped to key A (the key wins)", async () => { + const keyA = await createApiKey("m3hp-e-key-a", "machine-m3hp-ea", []); + const keyB = await createApiKey("m3hp-e-key-b", "machine-m3hp-eb", []); + const own = seedBatch(keyA.id, "m3hp-e-own"); + const other = seedBatch(keyB.id, "m3hp-e-other"); + const unowned = seedBatch(null, "m3hp-e-unowned"); + const headers = { Authorization: `Bearer ${keyA.key}`, cookie: await sessionCookie() }; + + const files = await listFilesVia(headers); + assert.strictEqual(files.res.status, 200); + const fileIds = new Set(files.body.data!.map((f) => f.id)); + assert.ok(fileIds.has(own.file.id)); + assert.ok(!fileIds.has(other.file.id), "a session cookie never widens a key's file list"); + assert.ok(!fileIds.has(unowned.file.id)); + + const batches = await listBatchesVia(headers); + assert.strictEqual(batches.res.status, 200); + const batchIds = new Set(batches.body.data!.map((b) => b.id)); + assert.ok(batchIds.has(own.batch.id)); + assert.ok(!batchIds.has(other.batch.id), "a session cookie never widens a key's batch list"); + assert.ok(!batchIds.has(unowned.batch.id)); + }); +}); + +describe("single-item routes — null-owner records (GHSA-2jm2-mpx8-6523)", () => { + it("(f) files: a null-owner file is 404 (metadata, content, delete) for a foreign key and for anonymous; 200 for a session", async () => { + const keyB = await createApiKey("2jm2-f-key-b", "machine-2jm2-fb", []); + const file = seedFile(null, "2jm2-f-null-owner"); + const foreign = { Authorization: `Bearer ${keyB.key}` }; + const anon = {}; + + for (const [label, headers] of [ + ["foreign key", foreign], + ["anonymous", anon], + ] as const) { + const meta = await getFileVia(headers, file.id); + assert.strictEqual(meta.status, 404, `${label}: GET /v1/files/{id} on a null-owner file`); + + const content = await getFileContentVia(headers, file.id); + assert.strictEqual(content.status, 404, `${label}: GET /v1/files/{id}/content`); + const contentBody = (await content.json()) as ErrorBody; + assert.strictEqual(contentBody.error?.message, "File not found", label); + + const del = await deleteFileVia(headers, file.id); + assert.strictEqual(del.status, 404, `${label}: DELETE /v1/files/{id}`); + assert.ok(getFile(file.id), `${label}: the null-owner file must survive`); + assert.strictEqual( + getFileContent(file.id)?.toString(), + "2jm2-f-null-owner", + `${label}: the null-owner file content must not be nulled` + ); + } + + const session = { cookie: await sessionCookie() }; + const meta = await getFileVia(session, file.id); + assert.strictEqual(meta.status, 200, "session: GET /v1/files/{id} on a null-owner file"); + const content = await getFileContentVia(session, file.id); + assert.strictEqual(content.status, 200, "session: GET /v1/files/{id}/content"); + assert.strictEqual(await content.text(), "2jm2-f-null-owner"); + const del = await deleteFileVia(session, file.id); + assert.strictEqual(del.status, 200, "session: DELETE /v1/files/{id}"); + assert.strictEqual(getFile(file.id), null, "session delete takes effect"); + }); + + it("(f) files: key-owned files keep the owner-only rule — owner 200, foreign key 404, anonymous 404, session 200", async () => { + const keyA = await createApiKey("2jm2-f2-key-a", "machine-2jm2-f2a", []); + const keyB = await createApiKey("2jm2-f2-key-b", "machine-2jm2-f2b", []); + const file = seedFile(keyA.id, "2jm2-f2-owned"); + + assert.strictEqual( + (await getFileVia({ Authorization: `Bearer ${keyA.key}` }, file.id)).status, + 200 + ); + assert.strictEqual( + (await getFileVia({ Authorization: `Bearer ${keyB.key}` }, file.id)).status, + 404 + ); + assert.strictEqual((await getFileVia({}, file.id)).status, 404); + assert.strictEqual((await getFileVia({ cookie: await sessionCookie() }, file.id)).status, 200); + assert.strictEqual( + (await getFileContentVia({ Authorization: `Bearer ${keyB.key}` }, file.id)).status, + 404 + ); + assert.strictEqual((await deleteFileVia({}, file.id)).status, 404); + assert.ok(getFile(file.id), "an anonymous delete on a key-owned file is a no-op"); + }); + + it("(f) batches: a null-owner batch is 404 (get, delete, cancel) for a foreign key and for anonymous; 200 for a session", async () => { + const keyB = await createApiKey("2jm2-fb-key-b", "machine-2jm2-fbb", []); + const terminal = seedBatch(null, "2jm2-fb-null-terminal", "completed"); + const live = seedBatch(null, "2jm2-fb-null-live", "validating"); + const foreign = { Authorization: `Bearer ${keyB.key}` }; + + for (const [label, headers] of [ + ["foreign key", foreign], + ["anonymous", {}], + ] as const) { + assert.strictEqual( + (await getBatchVia(headers, terminal.batch.id)).status, + 404, + `${label}: GET /v1/batches/{id} on a null-owner batch` + ); + assert.strictEqual( + (await deleteBatchVia(headers, terminal.batch.id)).status, + 404, + `${label}: DELETE /v1/batches/{id} on a null-owner batch` + ); + assert.ok(getBatch(terminal.batch.id), `${label}: the null-owner batch must survive`); + assert.ok(getFile(terminal.file.id), `${label}: its input file must survive`); + assert.strictEqual( + (await cancelBatchVia(headers, live.batch.id)).status, + 404, + `${label}: POST /v1/batches/{id}/cancel on a null-owner batch` + ); + assert.strictEqual(getBatch(live.batch.id)?.status, "validating", `${label}: not cancelled`); + } + + const session = { cookie: await sessionCookie() }; + assert.strictEqual((await getBatchVia(session, terminal.batch.id)).status, 200); + assert.strictEqual((await cancelBatchVia(session, live.batch.id)).status, 200); + assert.strictEqual( + getBatch(live.batch.id)?.status, + "cancelling", + "session cancel takes effect" + ); + assert.strictEqual((await deleteBatchVia(session, terminal.batch.id)).status, 200); + assert.strictEqual(getBatch(terminal.batch.id), null, "session delete takes effect"); + }); + + it("(g) POST /api/v1/batches: a foreign key or an anonymous caller cannot run a batch over a null-owner input file; the owner and a session can", async () => { + const keyA = await createApiKey("2jm2-g-key-a", "machine-2jm2-ga", []); + const keyB = await createApiKey("2jm2-g-key-b", "machine-2jm2-gb", []); + const unownedInput = seedFile(null, "2jm2-g-null-input"); + const ownedInput = seedFile(keyA.id, "2jm2-g-owned-input"); + + for (const [label, headers] of [ + ["foreign key", { Authorization: `Bearer ${keyB.key}` }], + ["anonymous", {}], + ] as const) { + const { res, body } = await createBatchVia(headers, unownedInput.id); + assert.strictEqual(res.status, 400, `${label}: batch over a null-owner input file`); + assert.strictEqual(body.error?.message, "Input file not found", label); + assert.strictEqual(body.id, undefined, `${label}: no batch created`); + } + + // Key B still cannot use key A's file (the pre-existing owner rule). + const foreignOwned = await createBatchVia( + { Authorization: `Bearer ${keyB.key}` }, + ownedInput.id + ); + assert.strictEqual(foreignOwned.res.status, 400, "key B over key A's input file"); + + // The owner can. + const owner = await createBatchVia({ Authorization: `Bearer ${keyA.key}` }, ownedInput.id); + assert.strictEqual(owner.res.status, 200, "key A over its own input file"); + assert.strictEqual(getBatch(owner.body.id!)?.apiKeyId, keyA.id); + + // The operator's session can — over the null-owner file AND over a key-owned one. + const session = { cookie: await sessionCookie() }; + const sessionUnowned = await createBatchVia(session, unownedInput.id); + assert.strictEqual(sessionUnowned.res.status, 200, "session over the null-owner input file"); + const sessionOwned = await createBatchVia(session, ownedInput.id); + assert.strictEqual(sessionOwned.res.status, 200, "session over key A's input file"); + }); + + it("(h) POST /api/v1/batches/{id}/cancel: a dashboard session cancels a KEY-owned batch (#13683); the owner can; a foreign key cannot", async () => { + const keyA = await createApiKey("2jm2-h-key-a", "machine-2jm2-ha", []); + const keyB = await createApiKey("2jm2-h-key-b", "machine-2jm2-hb", []); + const bySession = seedBatch(keyA.id, "2jm2-h-session", "validating"); + const byOwner = seedBatch(keyA.id, "2jm2-h-owner", "validating"); + + assert.strictEqual( + (await cancelBatchVia({ Authorization: `Bearer ${keyB.key}` }, bySession.batch.id)).status, + 404, + "a foreign key cannot cancel key A's batch" + ); + assert.strictEqual(getBatch(bySession.batch.id)?.status, "validating"); + + const session = await cancelBatchVia({ cookie: await sessionCookie() }, bySession.batch.id); + assert.strictEqual(session.status, 200, "the operator's dashboard cancels any batch"); + assert.strictEqual(getBatch(bySession.batch.id)?.status, "cancelling"); + + const owner = await cancelBatchVia({ Authorization: `Bearer ${keyA.key}` }, byOwner.batch.id); + assert.strictEqual(owner.status, 200, "the owning key cancels its own batch"); + assert.strictEqual(getBatch(byOwner.batch.id)?.status, "cancelling"); + }); +}); diff --git a/tests/unit/free-badge-provider-gate.test.ts b/tests/unit/free-badge-provider-gate.test.ts new file mode 100644 index 0000000000..34c1fdc0d5 --- /dev/null +++ b/tests/unit/free-badge-provider-gate.test.ts @@ -0,0 +1,211 @@ +/** + * Provider-page "Free" badge (#13645). + * + * A. Flag off (default): `isModelFreeBadge` is the historical dashboard rule — every badge + * the dashboard showed before still shows (compatible nodes with `:free`, name-labelled + * models, truthy `free` fields). + * B. Flag on (FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER): only badges that cannot be right are + * removed; `:free` stays on free-tier providers and on compatible/custom nodes. + * C. Catalog cross-check, derived from FREE_MODEL_BUDGETS (no hand-kept allowlist): every + * live catalogued free model keeps its badge under both rules; retired-only entries do + * not get one from the catalog alone. + * D. Auth bypass lock: the `credits_exhausted` exemption for free models in + * `src/sse/services/auth.ts` stays scoped to openrouter + free models. + */ +import { describe, it, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + FREE_BADGE_STRICT_FLAG, + isModelFreeBadge, + providerHasFreeModels, +} from "../../src/shared/utils/freeModels.ts"; +import { FREE_MODEL_BUDGETS, grantsFreeAccess } from "../../open-sse/config/freeModelCatalog.ts"; +import { getProviderById } from "../../src/shared/constants/providers.ts"; +import { FEATURE_FLAG_DEFINITIONS } from "../../src/shared/constants/featureFlagDefinitions.ts"; + +const PAID_REGISTERED = "openai"; // registered provider, no documented free tier +const FREE_TIER = "openrouter"; // documented free tier, implements `:free` +const COMPATIBLE_NODE = "openai-compatible-chat-7f3a"; // custom node, upstream unknown + +const LIVE = FREE_MODEL_BUDGETS.filter((m) => grantsFreeAccess(m.freeType)); +const liveIds = new Set(LIVE.map((m) => `${m.provider}/${m.modelId}`)); + +describe("fixtures", () => { + it("the providers used below have the properties the cases rely on", () => { + assert.ok(getProviderById(PAID_REGISTERED), `${PAID_REGISTERED} is registered`); + assert.equal(providerHasFreeModels(PAID_REGISTERED), false); + assert.ok(getProviderById(FREE_TIER), `${FREE_TIER} is registered`); + assert.equal(providerHasFreeModels(FREE_TIER), true); + assert.equal(getProviderById(COMPATIBLE_NODE), undefined); + assert.equal(providerHasFreeModels(COMPATIBLE_NODE), false); + }); + + it("the strict rule ships as an opt-in flag", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === FREE_BADGE_STRICT_FLAG); + assert.ok(def, "flag defined"); + assert.equal(def.defaultValue, "false"); + assert.equal(def.type, "boolean"); + }); +}); + +describe("A. flag off: historical badge rule unchanged", () => { + const legacy = (provider: string, model: Parameters[1]) => + isModelFreeBadge(provider, model); + + it("keeps the badge on compatible nodes pointing at :free models", () => { + assert.equal(legacy(COMPATIBLE_NODE, { id: "meta-llama/llama-3.3-70b:free" }), true); + }); + + it("keeps name-labelled and truthy-field badges", () => { + assert.equal(legacy(PAID_REGISTERED, { id: "chat-x", name: "Chat X (Free)" }), true); + assert.equal(legacy(PAID_REGISTERED, { id: "chat-y", name: "Modelo grátis" }), true); + assert.equal(legacy(PAID_REGISTERED, { id: "chat-z", free: "yes" }), true); + assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9:free" }), true); + }); + + it("does not badge a plain paid model", () => { + assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9", name: "GPT 9" }), false); + assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9", name: "Freeform writer" }), false); + }); +}); + +describe("B. flag on: only provably wrong badges are removed", () => { + const strict = (provider: string, model: Parameters[1]) => + isModelFreeBadge(provider, model, { strict: true }); + + it("keeps :free on compatible nodes and on free-tier providers", () => { + assert.equal(strict(COMPATIBLE_NODE, { id: "meta-llama/llama-3.3-70b:free" }), true); + assert.equal(strict(FREE_TIER, { id: "meta-llama/llama-3.3-70b:free" }), true); + }); + + it("keeps explicit boolean free signals on any provider", () => { + assert.equal(strict(PAID_REGISTERED, { id: "promo", isFree: true }), true); + assert.equal(strict(PAID_REGISTERED, { id: "promo", free: true }), true); + assert.equal(strict(COMPATIBLE_NODE, { id: "local-model", free: true }), true); + }); + + it("drops the name heuristic, non-boolean free fields and :free on paid registered providers", () => { + assert.equal(strict(PAID_REGISTERED, { id: "chat-x", name: "Chat X (Free)" }), false); + assert.equal(strict(PAID_REGISTERED, { id: "chat-z", free: "false" }), false); + assert.equal(strict(PAID_REGISTERED, { id: "gpt-9:free" }), false); + assert.equal(strict(COMPATIBLE_NODE, { id: "chat-x", name: "Free chat" }), false); + }); + + it("never adds a badge the historical rule did not show", () => { + const cases: Array<[string, Parameters[1]]> = [ + [PAID_REGISTERED, { id: "gpt-9" }], + [PAID_REGISTERED, { id: "gpt-9", isFree: "true" }], + [COMPATIBLE_NODE, { id: "x", free: 0 }], + [FREE_TIER, { id: "paid/model", name: "paid" }], + ...LIVE.slice(0, 20).map((m) => [m.provider, { id: m.modelId }] as [string, { id: string }]), + ]; + for (const [provider, model] of cases) { + if (isModelFreeBadge(provider, model, { strict: true })) { + assert.equal(isModelFreeBadge(provider, model), true, `${provider}/${model.id}`); + } + } + }); +}); + +describe("C. catalog cross-check (derived from FREE_MODEL_BUDGETS)", () => { + it("every live catalogued free model keeps its badge under both rules", () => { + assert.ok(LIVE.length > 0, "catalog has live free entries"); + for (const entry of LIVE) { + const model = { id: entry.modelId }; + assert.equal( + isModelFreeBadge(entry.provider, model), + true, + `${entry.provider}/${entry.modelId}` + ); + assert.equal( + isModelFreeBadge(entry.provider, model, { strict: true }), + true, + `strict ${entry.provider}/${entry.modelId}` + ); + } + }); + + it("a retired-only catalog entry earns no badge from the catalog itself", () => { + const retiredOnly = FREE_MODEL_BUDGETS.filter( + (m) => + !grantsFreeAccess(m.freeType) && + !liveIds.has(`${m.provider}/${m.modelId}`) && + !m.modelId.endsWith(":free") && + !/\bgr[aá]tis\b|\bfree\b/i.test(m.modelId) + ); + assert.ok(retiredOnly.length > 0, "catalog has retired-only entries"); + for (const entry of retiredOnly) { + assert.equal( + isModelFreeBadge(entry.provider, { id: entry.modelId }, { strict: true }), + false, + `${entry.provider}/${entry.modelId}` + ); + } + }); +}); + +describe("D. auth bypass lock", () => { + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-badge-gate-")); + process.env.DATA_DIR = TEST_DATA_DIR; + + let core: typeof import("../../src/lib/db/core.ts"); + let providersDb: typeof import("../../src/lib/db/providers.ts"); + let auth: typeof import("../../src/sse/services/auth.ts"); + + test.before(async () => { + core = await import("../../src/lib/db/core.ts"); + providersDb = await import("../../src/lib/db/providers.ts"); + auth = await import("../../src/sse/services/auth.ts"); + }); + + test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + } + + test("catalogued free id without :free suffix is still served on credits_exhausted", async () => { + await resetStorage(); + const live = LIVE.find((m) => m.provider === "openrouter" && !m.modelId.endsWith(":free")); + assert.ok(live, "openrouter must have a live catalogued id without :free suffix"); + await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + apiKey: "sk-or-exhausted-catalog", + isActive: true, + testStatus: "credits_exhausted", + }); + const selected = await auth.getProviderCredentials("openrouter", null, null, live.modelId); + assert.ok(selected && "connectionId" in selected, "catalogued free id must bypass the lock"); + }); + + test("expired status still refuses a :free model", async () => { + await resetStorage(); + await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + apiKey: "sk-or-expired", + isActive: true, + testStatus: "expired", + }); + const selected = await auth.getProviderCredentials( + "openrouter", + null, + null, + "meta-llama/llama-3.1-8b-instruct:free" + ); + assert.deepEqual(selected, { + allExpired: true, + expiredCount: 1, + expiredStatus: "expired", + }); + }); +}); diff --git a/tests/unit/guardrails/vision-bridge-claude-wire.test.ts b/tests/unit/guardrails/vision-bridge-claude-wire.test.ts index 7cba4f4044..2a7b4179e3 100644 --- a/tests/unit/guardrails/vision-bridge-claude-wire.test.ts +++ b/tests/unit/guardrails/vision-bridge-claude-wire.test.ts @@ -5,11 +5,28 @@ */ import test from "node:test"; import assert from "node:assert/strict"; +import dns from "node:dns"; -const { - isClaudeWireFormatModel, - ensureBase64ImagesForClaudeWire, -} = await import("../../../src/lib/guardrails/visionBridgeHelpers.ts"); +// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard +// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts). +// Since GHSA-34rg-3pqj-35g9 the vision bridge pins `guard: "public-only"`, so every +// remote image hostname is resolved before the injected fetch is reached; the +// example.com hosts below must not depend on real DNS in CI. Node --test runs each +// file in its own process, so this rebinding does not leak across files. +const originalDnsLookup = dns.promises.lookup; +(dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } +) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; +}) as typeof dns.promises.lookup; +process.on("exit", () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; +}); + +const { isClaudeWireFormatModel, ensureBase64ImagesForClaudeWire } = + await import("../../../src/lib/guardrails/visionBridgeHelpers.ts"); test("isClaudeWireFormatModel: true for anthropic and claude-format registry providers", () => { assert.strictEqual(isClaudeWireFormatModel("anthropic/claude-sonnet-4"), true); @@ -61,7 +78,8 @@ test("ensureBase64ImagesForClaudeWire: keeps data-URI images as-is", async () => }); test("ensureBase64ImagesForClaudeWire: resolves remote URLs to base64 for claude-wire targets", async () => { - const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response(new Uint8Array(Buffer.from(pngBase64, "base64")), { @@ -127,3 +145,62 @@ test("ensureBase64ImagesForClaudeWire: fail-open when the remote fetch fails", a globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the vision bridge inlines a user-supplied `image_url` to base64 for +// claude-wire targets (`visionBridge.ts` reroute) and for the Anthropic describe self-call. +// `fetchRemoteImageAsDataUri()` called `fetchRemoteImage()` with only `{ signal, fetchImpl }`, +// so the URL was validated under the OPERATOR outbound policy (`block-metadata` on a +// default install: loopback/LAN allowed, DNS check skipped) instead of `public-only`. The +// helper is fail-open, so the observable contract is: the injected fetch is NEVER invoked +// for a private host and the part is left untouched (not inlined). +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`ensureBase64ImagesForClaudeWire: never fetches a private image_url (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const fetchedUrls: string[] = []; + const body = { + model: "zai/glm-5", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: privateUrl } }], + }, + ], + }; + + const out = await ensureBase64ImagesForClaudeWire(body, "zai/glm-5", async (input) => { + fetchedUrls.push(String(input)); + // Canary: on the vulnerable code these bytes are inlined into the rerouted body. + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + assert.deepStrictEqual(fetchedUrls, [], "the private URL must never be fetched"); + const part = out.messages[0].content[0]; + assert.strictEqual(part.image_url.url, privateUrl, "part must be left untouched (fail-open)"); + }); +} + +test("ensureBase64ImagesForClaudeWire: still inlines a public image_url whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + // The module-level DNS stub answers a public IP, so the `public-only` rebinding guard + // passes and the injected fetch is reached. + const fetchedUrls: string[] = []; + const body = { + model: "zai/glm-5", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://cdn.example.com/public.png" } }], + }, + ], + }; + const out = await ensureBase64ImagesForClaudeWire(body, "zai/glm-5", async (input) => { + fetchedUrls.push(String(input)); + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + assert.deepStrictEqual(fetchedUrls, ["https://cdn.example.com/public.png"]); + assert.ok(out.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")); +}); diff --git a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts index 0303a9a885..a4baa63a3f 100644 --- a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts +++ b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts @@ -417,3 +417,46 @@ test("callVisionModel propagates an external abort to fetch and stops before fal globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the Anthropic describe self-call inlines the user's image URL to +// base64 through the same `fetchRemoteImageAsDataUri()` sink as the claude-wire reroute. +// The DNS stub at the top of this file answers a public IP for every hostname, so only the +// `public-only` string check stands between the request body and a loopback/RFC-1918 fetch. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`callVisionModel never fetches a private image URL (${privateUrl}) for the Anthropic describe path (GHSA-34rg-3pqj-35g9)`, async () => { + const fetchedUrls: string[] = []; + const fetchImpl: typeof fetch = async (url) => { + const requestUrl = String(url); + fetchedUrls.push(requestUrl); + if (requestUrl === privateUrl) { + // Canary: on the vulnerable code these bytes are inlined into the Anthropic body. + return new Response(Buffer.from("intranet-bytes"), { + status: 200, + headers: { "Content-Type": "image/png" }, + }); + } + return new Response(JSON.stringify({ content: [{ type: "text", text: "described" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const config: VisionModelConfig = { + model: "anthropic/claude-3-haiku", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + fetchImpl, + }; + + await assert.rejects( + () => callVisionModel(privateUrl, config, "sk-ant", { maxFallbackAttempts: 1 }), + /blocked/i + ); + assert.deepStrictEqual( + fetchedUrls, + [], + "neither the private download nor the self-call may happen" + ); + }); +} diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 9842b956cd..4092f3e807 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -2130,3 +2130,105 @@ test("handleImageGeneration (codex) does not mark an ordinary 400 as retryable", globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — caller-supplied image URLs (`image_url` / `mask_url` / message +// parts) reach `fetchRemoteImage()` through `resolveImageSource()`. Without an explicit +// `guard`, the library falls back to `getProviderOutboundGuard()` — the OPERATOR outbound +// policy, which is `block-metadata` on a default install (LAN/loopback allowed, DNS +// rebinding check skipped) — so a request body could make the server fetch intranet +// URLs and forward the bytes upstream. Caller input must be pinned to `public-only` +// regardless of the operator policy. The DNS stub at the top of this file resolves every +// hostname to a public IP, so the string check is the only thing standing between the +// request body and the loopback/RFC-1918 fetch. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`handleImageGeneration rejects a private image_url (${privateUrl}) before any fetch (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls = []; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + fetchedUrls.push(stringUrl); + if (stringUrl === privateUrl) { + // Canary: on the vulnerable code the sink downloads these bytes and + // forwards them to Stability as the multipart `image` part. + return new Response(new Uint8Array([4, 5]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/inpaint", + prompt: "replace the sky with aurora", + image_url: privateUrl, + mask: "data:image/png;base64,AA==", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.deepEqual( + fetchedUrls, + [], + "neither the private image download nor the upstream call may happen" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("handleImageGeneration still downloads a public image_url whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls = []; + let requestCapture; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + fetchedUrls.push(stringUrl); + if (stringUrl === "https://cdn.example.com/public-input.png") { + return new Response(new Uint8Array([4, 5, 6]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + if (stringUrl === "https://api.stability.ai/v2beta/stable-image/edit/inpaint") { + requestCapture = { body: options.body }; + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/inpaint", + prompt: "replace the sky with aurora", + image_url: "https://cdn.example.com/public-input.png", + mask: "data:image/png;base64,AA==", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(fetchedUrls[0], "https://cdn.example.com/public-input.png"); + assert.equal((requestCapture.body.get("image") as Blob).size, 3); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/image-upscale.test.ts b/tests/unit/image-upscale.test.ts index b6eda4c365..851be6e0bc 100644 --- a/tests/unit/image-upscale.test.ts +++ b/tests/unit/image-upscale.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert"; +import dns from "node:dns"; import { DEFAULT_UPSCALE_FACTORS, UPSCALE_PROVIDERS, @@ -25,6 +26,7 @@ import { import { extractUpscaleSourceImage, readImageDimensions, + resolveUpscaleImageSource, scaleDimensions, sniffImageMime, } from "../../open-sse/handlers/imageUpscale/shared.ts"; @@ -67,7 +69,12 @@ function jpegHeader(width: number, height: number): Buffer { const FAKE_JWT = (() => { const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url"); const payload = Buffer.from( - JSON.stringify({ user_id: "TESTUSER@AdobeID", type: "access_token", created_at: "1", expires_in: "86400000" }) + JSON.stringify({ + user_id: "TESTUSER@AdobeID", + type: "access_token", + created_at: "1", + expires_in: "86400000", + }) ).toString("base64url"); return `${header}.${payload}.sig`; })(); @@ -104,7 +111,12 @@ test("adobe-firefly upscale models are Topaz only (video starlight/astra exclude const ids = UPSCALE_PROVIDERS["adobe-firefly"]!.models.map((m) => m.id); assert.deepEqual(ids, ["topaz", "topaz-standard", "topaz-bloom"]); for (const id of ids) assert.ok(id.startsWith("topaz"), `${id} must be a Topaz model`); - for (const forbidden of ["starlight-quality", "starlight-creative", "starlight-fast", "astra-2"]) { + for (const forbidden of [ + "starlight-quality", + "starlight-creative", + "starlight-fast", + "astra-2", + ]) { assert.ok(!ids.includes(forbidden), `${forbidden} is a video upscaler and must not be listed`); } }); @@ -133,7 +145,10 @@ test("parseUpscaleModel accepts provider prefix, alias and bare model ids", () = provider: "stability-ai", model: "creative", }); - assert.deepEqual(parseUpscaleModel("topaz-enhance"), { provider: "topaz", model: "topaz-enhance" }); + assert.deepEqual(parseUpscaleModel("topaz-enhance"), { + provider: "topaz", + model: "topaz-enhance", + }); assert.equal(parseUpscaleModel("openai/gpt-image-2").provider, null); assert.deepEqual(parseUpscaleModel(null), { provider: null, model: null }); }); @@ -211,7 +226,10 @@ test("resolveAdobeUpscaleModel maps ids to upstream topaz versions and rejects o resolveAdobeUpscaleModel("adobe-firefly/topaz-bloom")?.spec.upstreamModelId, "topaz" ); - assert.equal(resolveAdobeUpscaleModel("firefly/reimagine")?.spec.upstreamModelVersion, "reimagine"); + assert.equal( + resolveAdobeUpscaleModel("firefly/reimagine")?.spec.upstreamModelVersion, + "reimagine" + ); assert.equal(resolveAdobeUpscaleModel("nano-banana-pro"), null); assert.equal(resolveAdobeUpscaleModel(""), null); assert.equal(isAdobeFireflyUpscaleModel("topaz-bloom"), true); @@ -232,7 +250,10 @@ test("resolveAdobeCreativityLevel maps 0-100 % onto the 0-1 upsample wire float" assert.equal(resolveAdobeCreativityLevel({ creativityPercent: 40 }), 0.4); assert.equal(resolveAdobeCreativityLevel({}), 0); // Explicit 0-1 wins over percent. - assert.equal(resolveAdobeCreativityLevel({ creativityPercent: 100, creativityLevel: 0.25 }), 0.25); + assert.equal( + resolveAdobeCreativityLevel({ creativityPercent: 100, creativityLevel: 0.25 }), + 0.25 + ); // Legacy 1-5 integer scale (discovery docs) is mapped onto 0-1. assert.equal(resolveAdobeCreativityLevel({ creativityLevel: "4" }), 0.8); assert.equal(resolveAdobeCreativityLevel({ creativityLevel: 5 }), 1); @@ -359,9 +380,15 @@ test("adobeFireflyUpscaleImage rejects a non-upscale model and a missing blob", // ── Shared helpers ───────────────────────────────────────────────────────── test("extractUpscaleSourceImage finds the first image across every alias", () => { - assert.equal(extractUpscaleSourceImage({ image: "data:image/png;base64,AAA" }), "data:image/png;base64,AAA"); + assert.equal( + extractUpscaleSourceImage({ image: "data:image/png;base64,AAA" }), + "data:image/png;base64,AAA" + ); assert.equal(extractUpscaleSourceImage({ image_url: "https://x/y.png" }), "https://x/y.png"); - assert.equal(extractUpscaleSourceImage({ images: ["https://a/1.png", "https://a/2.png"] }), "https://a/1.png"); + assert.equal( + extractUpscaleSourceImage({ images: ["https://a/1.png", "https://a/2.png"] }), + "https://a/1.png" + ); assert.equal( extractUpscaleSourceImage({ image_url: { url: "https://obj/u.png" } }), "https://obj/u.png" @@ -372,7 +399,9 @@ test("extractUpscaleSourceImage finds the first image across every alias", () => ); assert.equal( extractUpscaleSourceImage({ - messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://m/1.png" } }] }], + messages: [ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://m/1.png" } }] }, + ], }), "https://m/1.png" ); @@ -409,7 +438,10 @@ test("scaleDimensions multiplies the source size and clamps the long edge", () = // ── Dispatcher ───────────────────────────────────────────────────────────── test("handleImageUpscale rejects unknown / mismatched models before any network call", async () => { - const badModel = await handleImageUpscale({ body: { model: "openai/gpt-image-2" }, credentials: {} }); + const badModel = await handleImageUpscale({ + body: { model: "openai/gpt-image-2" }, + credentials: {}, + }); assert.equal(badModel.success, false); assert.equal(badModel.status, 400); assert.match(String(badModel.error), /Invalid upscale model/); @@ -428,7 +460,11 @@ test("handleImageUpscale rejects unknown / mismatched models before any network }); test("handleImageUpscale requires a source image for every provider", async () => { - for (const model of ["adobe-firefly/topaz-standard", "stability-ai/fast", "topaz/topaz-enhance"]) { + for (const model of [ + "adobe-firefly/topaz-standard", + "stability-ai/fast", + "topaz/topaz-enhance", + ]) { const result = await handleImageUpscale({ body: { model }, credentials: { apiKey: "k" }, @@ -590,7 +626,10 @@ test("topaz falls back to its own scale when the source dimensions are unreadabl credentials: { apiKey: "topaz-key" }, fetchImpl: (async (_url: unknown, init?: RequestInit) => { form = init?.body as FormData; - return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); }) as unknown as typeof fetch, }); @@ -614,7 +653,10 @@ test("topaz honors an explicit WxH size over the factor and propagates upstream credentials: { apiKey: "topaz-key" }, fetchImpl: (async (_url: unknown, init?: RequestInit) => { form = init?.body as FormData; - return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); }) as unknown as typeof fetch, }); assert.equal(form!.get("output_width"), "1500"); @@ -633,3 +675,102 @@ test("topaz honors an explicit WxH size over the factor and propagates upstream assert.equal(failed.status, 402); assert.match(String(failed.error), /quota exceeded/); }); + +// ── GHSA-34rg-3pqj-35g9 — caller-supplied source URL must be public-only ─── +// +// `resolveUpscaleImageSource()` is fed straight from the request body (14 aliases, +// `provider_options.*`, message parts). It called `fetchRemoteImage()` with no explicit +// `guard`, so it inherited `getProviderOutboundGuard()` — the OPERATOR outbound policy, +// `block-metadata` on a default install (loopback/LAN allowed, DNS check skipped) — and +// a request body could make the server fetch intranet URLs and upload the bytes upstream. + +/** Public-IP DNS stub (rebinding guard needs a non-empty public answer for a fake host). */ +function withPublicDns(run: () => Promise): Promise { + const originalLookup = dns.promises.lookup; + (dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } + ) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; + }) as typeof dns.promises.lookup; + return run().finally(() => { + (dns.promises as { lookup: unknown }).lookup = originalLookup; + }); +} + +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`resolveUpscaleImageSource rejects a private source URL (${privateUrl}) before any fetch (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }) as unknown as typeof fetch; + + try { + await assert.rejects(() => resolveUpscaleImageSource(privateUrl), /blocked/i); + assert.deepEqual(fetchedUrls, [], "the private URL must never be fetched"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test(`stability upscale never uploads bytes from a private image_url (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + // Canary: on the vulnerable code these bytes become the multipart `image` part. + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }) as unknown as typeof fetch; + let upstreamCalls = 0; + + try { + const result = await handleStabilityImageUpscale({ + model: "fast", + provider: "stability-ai", + providerConfig: { baseUrl: "https://api.stability.ai" }, + body: { image_url: privateUrl, response_format: "b64_json" }, + credentials: { apiKey: "sk-test" }, + fetchImpl: (async () => { + upstreamCalls += 1; + return jsonResponse({ image: PNG_1X1.toString("base64") }); + }) as unknown as typeof fetch, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.deepEqual(fetchedUrls, [], "the private URL must never be fetched"); + assert.equal(upstreamCalls, 0, "nothing may be uploaded to the provider"); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + }) as unknown as typeof fetch; + + try { + const source = await withPublicDns(() => + resolveUpscaleImageSource("https://cdn.example.com/public.png") + ); + assert.equal(source.contentType, "image/png"); + assert.equal(source.buffer.length, PNG_1X1.length); + assert.deepEqual(fetchedUrls, ["https://cdn.example.com/public.png"]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/local-token-budget-429-skips-cooldown.test.ts b/tests/unit/local-token-budget-429-skips-cooldown.test.ts new file mode 100644 index 0000000000..bb21d9329b --- /dev/null +++ b/tests/unit/local-token-budget-429-skips-cooldown.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * A locally rejected token-budget 429 must not cool the connection. + * + * The per-key token ceiling rejects the request before any upstream call + * (status 429 + TOKEN_LIMIT_EXCEEDED). That is a request-scoped refusal, not + * a connection health signal, so `shouldSkipConnDisable` must return true and + * the direct-path caller (chat.ts) must skip `markAccountUnavailable`. + */ + +const { shouldSkipConnDisable, isRequestScopedUpstreamFailure } = + await import("../../open-sse/services/combo/comboPredicates.ts"); + +const BASE_ARGS = { is401: false, hasExtraKeys: false, provider: "test-provider" } as const; + +test("token-budget 429 skips connection disable", () => { + assert.equal( + shouldSkipConnDisable( + { status: 429, errorCode: "TOKEN_LIMIT_EXCEEDED" }, + BASE_ARGS.is401, + BASE_ARGS.hasExtraKeys, + BASE_ARGS.provider + ), + true, + "a locally rejected token-budget 429 must not cool a healthy connection" + ); +}); + +test("token-budget code matches case-insensitively", () => { + for (const errorCode of ["token_limit_exceeded", "Token_Limit_Exceeded"]) { + assert.equal( + shouldSkipConnDisable( + { status: 429, errorCode }, + BASE_ARGS.is401, + BASE_ARGS.hasExtraKeys, + BASE_ARGS.provider + ), + true, + `errorCode ${errorCode} must skip connection disable` + ); + } +}); + +test("token-budget code is request-scoped", () => { + assert.equal( + isRequestScopedUpstreamFailure({ code: "TOKEN_LIMIT_EXCEEDED", type: null }), + true, + "TOKEN_LIMIT_EXCEEDED must classify as request-scoped" + ); +}); + +test("skip decision precedes markAccountUnavailable, so no cooldown is written", () => { + const skipConnectionDisable = shouldSkipConnDisable( + { status: 429, errorCode: "TOKEN_LIMIT_EXCEEDED" }, + BASE_ARGS.is401, + BASE_ARGS.hasExtraKeys, + BASE_ARGS.provider + ); + let cooldownWrites = 0; + if (!skipConnectionDisable) cooldownWrites += 1; + assert.equal(skipConnectionDisable, true); + assert.equal(cooldownWrites, 0, "skipped decisions must never reach the cooldown write"); +}); + +test("a real upstream 429 still cools the connection", () => { + assert.equal( + shouldSkipConnDisable( + { status: 429, errorCode: null, errorType: null }, + BASE_ARGS.is401, + BASE_ARGS.hasExtraKeys, + BASE_ARGS.provider + ), + false, + "an unlabelled upstream 429 is a health signal and must still cool down" + ); +}); + +test("GEMINI_TPM_EXHAUSTED stays out of scope", () => { + assert.equal( + shouldSkipConnDisable( + { status: 429, errorCode: "GEMINI_TPM_EXHAUSTED" }, + BASE_ARGS.is401, + BASE_ARGS.hasExtraKeys, + BASE_ARGS.provider + ), + false, + "unproven codes must not ride along without their own evidence" + ); +}); diff --git a/tests/unit/nanobanana-image-handler.test.ts b/tests/unit/nanobanana-image-handler.test.ts index 0e956e6d56..fc438dff46 100644 --- a/tests/unit/nanobanana-image-handler.test.ts +++ b/tests/unit/nanobanana-image-handler.test.ts @@ -142,3 +142,66 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the `response_format=b64_json` path re-fetches the result URL the +// upstream task reports. That URL is upstream-supplied (lower risk than a request-body +// URL), but it went through `fetchRemoteImage()` with no explicit `guard`, i.e. under the +// OPERATOR outbound policy (`block-metadata` on a default install: LAN/loopback allowed, +// DNS check skipped). Pin it to `public-only`, mirroring the AI Horde result download. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`handleImageGeneration(nanobanana): b64_json never downloads a private result URL (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + + globalThis.fetch = async (url) => { + const u = String(url); + fetchedUrls.push(u); + + if (u.includes("/generate")) { + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-ssrf-1" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u.includes("/record-info")) { + return new Response( + JSON.stringify({ + code: 200, + msg: "success", + data: { successFlag: 1, response: { resultImageUrl: privateUrl } }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u === privateUrl) { + // Canary: on the vulnerable code these bytes come back to the caller as b64_json. + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 }); + } + + throw new Error(`Unexpected URL: ${u}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "nanobanana/nanobanana-flash", + prompt: "galaxy test", + response_format: "b64_json", + }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.ok( + !fetchedUrls.includes(privateUrl), + `the private result URL must never be fetched (fetched: ${fetchedUrls.join(", ")})` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} diff --git a/tests/unit/opencode-go-muse-spark-1-3-12674.test.ts b/tests/unit/opencode-go-muse-spark-1-3-12674.test.ts new file mode 100644 index 0000000000..bc9f485fb0 --- /dev/null +++ b/tests/unit/opencode-go-muse-spark-1-3-12674.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseEffortLevel, + resolveOpencodeTargetFormat, +} from "../../open-sse/executors/opencode.ts"; + +const { REGISTRY } = (await import("../../open-sse/config/providerRegistry.ts")) as { + REGISTRY: Record< + string, + { + models?: Array<{ + id: string; + targetFormat?: string; + supportsReasoning?: boolean; + contextLength?: number; + maxOutputTokens?: number; + }>; + } + >; +}; + +// #12674: opencode-go/muse-spark-1.3-contributor (upstream release 2026-09-02) +// 500s via /v1/chat/completions because the upstream serves Muse Spark only on +// the Responses API and the model had no registry targetFormat entry, so the +// executor fell back to "openai". Base + effort-tier alias set verified via +// `opencode models opencode-go --refresh --verbose` (minimal/low/medium/high/ +// xhigh, no max — same as 1.2). +const BASE = "muse-spark-1.3-contributor"; +const ALIASES = ["minimal", "low", "medium", "high", "xhigh"].map((effort) => ({ + alias: `${BASE}-${effort}`, + effort, +})); + +function goModels() { + const entry = REGISTRY["opencode-go"]; + assert.ok(entry, "opencode-go registry entry must exist"); + return entry.models ?? []; +} + +test("#12674 registry: 1.3 base + effort aliases target the Responses API", () => { + const models = goModels(); + for (const id of [BASE, ...ALIASES.map((a) => a.alias)]) { + const model = models.find((m) => m.id === id); + assert.ok(model, `${id} must be registered on opencode-go`); + assert.equal(model?.targetFormat, "openai-responses", `${id} must target Responses`); + assert.equal(model?.supportsReasoning, true, `${id} must support reasoning`); + assert.equal(model?.contextLength, 1048576, `${id} context must be 1M`); + assert.equal(model?.maxOutputTokens, 131072, `${id} max output must be 128K`); + } +}); + +test("#12674 executor: 1.3 resolves to openai-responses (URL selection)", () => { + assert.equal(resolveOpencodeTargetFormat("opencode-go", BASE), "openai-responses"); + assert.equal(resolveOpencodeTargetFormat("opencode-go", `${BASE}-high`), "openai-responses"); +}); + +for (const { alias, effort } of ALIASES) { + test(`#12674 parseEffortLevel: ${alias} → ${effort}`, () => { + assert.deepEqual(parseEffortLevel(alias), { baseModel: BASE, effort }); + }); +} + +test("#12674 parseEffortLevel: 1.3 has no max tier", () => { + assert.equal(parseEffortLevel(`${BASE}-max`), null); +}); + +test("#12674 catalog: 1.3 aliases stay Go-only (not on opencode-zen)", () => { + const zenIds = new Set((REGISTRY["opencode-zen"]?.models ?? []).map((m) => m.id)); + assert.equal(zenIds.has(BASE), false, "opencode-zen must not expose 1.3 base"); + for (const { alias } of ALIASES) { + assert.equal(zenIds.has(alias), false, `opencode-zen must not expose ${alias}`); + } +}); diff --git a/tests/unit/opencode-muse-spark-1-3-responses-12674.test.ts b/tests/unit/opencode-muse-spark-1-3-responses-12674.test.ts new file mode 100644 index 0000000000..fcb70fdadf --- /dev/null +++ b/tests/unit/opencode-muse-spark-1-3-responses-12674.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { opencode_zenProvider } from "../../open-sse/config/providers/registry/opencode/zen/index.ts"; +import { opencodeProvider } from "../../open-sse/config/providers/registry/opencode/index.ts"; +import { getTokenLimit } from "../../open-sse/services/contextManager.ts"; +import { + OpencodeExecutor, + resolveOpencodeTargetFormat, +} from "../../open-sse/executors/opencode.ts"; + +// Issue 12674: Muse Spark 1.3 is served on the Responses API with a 1M +// context window. These entries only set the wire format; without the +// explicit window the limit fell back to the 200000 provider default. +const PROVIDERS = [ + { id: "opencode-zen", entry: opencode_zenProvider }, + { id: "opencode", entry: opencodeProvider }, +] as const; +const MODEL_IDS = ["muse-spark-1.3", "muse-spark-1.3-contributor-free"] as const; + +test("muse-spark-1.3 models target the Responses API with reasoning enabled", () => { + for (const { id, entry } of PROVIDERS) { + for (const modelId of MODEL_IDS) { + const model = entry.models.find((m) => m.id === modelId); + assert.ok(model, `${modelId} should be registered on ${id}`); + assert.equal( + model?.targetFormat, + "openai-responses", + `${modelId} on ${id} must target the Responses API, not the default chat/completions pass-through` + ); + assert.equal(model?.supportsReasoning, true); + } + } +}); + +test("muse-spark-1.3 models declare the real 1M context window and 128K output limit", () => { + for (const { id, entry } of PROVIDERS) { + for (const modelId of MODEL_IDS) { + const model = entry.models.find((m) => m.id === modelId); + assert.ok(model, `${modelId} should be registered on ${id}`); + assert.equal(model?.contextLength, 1048576, `${modelId} on ${id} must declare a 1M window`); + assert.equal( + model?.maxOutputTokens, + 131072, + `${modelId} on ${id} must declare a 128K output limit` + ); + } + } +}); + +test("context limit resolves muse-spark-1.3 models to the 1M window, not the provider default", () => { + for (const { id } of PROVIDERS) { + for (const modelId of MODEL_IDS) { + assert.equal( + getTokenLimit(id, modelId), + 1048576, + `getTokenLimit(${id}, ${modelId}) must return the 1M window` + ); + } + } +}); + +test("executor resolves muse-spark-1.3 models to the Responses API", () => { + for (const { id } of PROVIDERS) { + for (const modelId of MODEL_IDS) { + assert.equal( + resolveOpencodeTargetFormat(id, modelId), + "openai-responses", + `resolveOpencodeTargetFormat(${id}, ${modelId}) must return openai-responses` + ); + } + } +}); + +test("Responses-format executor hits /responses with x-api-key auth and no Bearer header", () => { + for (const { id } of PROVIDERS) { + for (const modelId of MODEL_IDS) { + const executor = new OpencodeExecutor(id); + executor._requestFormat = resolveOpencodeTargetFormat(id, modelId); + const url = executor.buildUrl(modelId, true); + assert.ok( + url.endsWith("/responses"), + `${id}/${modelId} must build a /responses URL, got ${url}` + ); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, null, modelId); + assert.equal(headers["x-api-key"], "sk-test"); + assert.equal(headers["Authorization"], undefined); + } + } +}); diff --git a/tests/unit/partial-without-defaults.test.ts b/tests/unit/partial-without-defaults.test.ts new file mode 100644 index 0000000000..22a0ebea0c --- /dev/null +++ b/tests/unit/partial-without-defaults.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +// zod 4 applies .default() even under .partial(): an update schema built that way hands the +// creation default back for every field the client omitted, and the merge that follows +// overwrites the stored value. These tests pin the helper and scan every exported update +// schema so a new one cannot bring the problem back. + +const { partialWithoutDefaults } = + await import("../../src/shared/validation/partialWithoutDefaults.ts"); + +const source = z + .object({ + name: z.string().min(1), + enabled: z.boolean().optional().default(true), + priority: z.number().default(0), + note: z.string().optional(), + }) + .strict(); + +test("an omitted field stays absent instead of taking its creation default", () => { + assert.deepEqual(source.partial().parse({ name: "n" }), { + name: "n", + enabled: true, + priority: 0, + }); + assert.deepEqual(partialWithoutDefaults(source).parse({ name: "n" }), { name: "n" }); + assert.deepEqual(partialWithoutDefaults(source).parse({}), {}); +}); + +test("a sent field is still validated and kept", () => { + const update = partialWithoutDefaults(source); + assert.deepEqual(update.parse({ enabled: false, priority: 3 }), { enabled: false, priority: 3 }); + assert.equal(update.safeParse({ priority: "high" }).success, false); + assert.equal(update.safeParse({ name: "" }).success, false); +}); + +test("the unknown-key policy of the source object is kept", () => { + assert.equal(partialWithoutDefaults(source).safeParse({ typo: 1 }).success, false); + const loose = z.object({ a: z.string().default("x") }); + assert.deepEqual(partialWithoutDefaults(loose).parse({ typo: 1 }), {}); +}); + +test("the source schema keeps its defaults for creation", () => { + partialWithoutDefaults(source); + assert.deepEqual(source.parse({ name: "n" }), { name: "n", enabled: true, priority: 0 }); +}); + +function hasDefault(field: unknown): boolean { + let current = field; + for (let depth = 0; depth < 4; depth++) { + if (current instanceof z.ZodDefault || current instanceof z.ZodPrefault) return true; + if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) { + current = current.unwrap(); + } else { + return false; + } + } + return false; +} + +function listTsFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return listTsFiles(full); + return entry.name.endsWith(".ts") ? [full] : []; + }); +} + +test("no exported update or patch schema re-applies a creation default", async () => { + const roots = ["src/shared/schemas", "src/shared/validation"].map((dir) => path.resolve(dir)); + const offenders: string[] = []; + let scanned = 0; + for (const file of roots.flatMap(listTsFiles)) { + const mod = (await import(file)) as Record; + for (const [name, schema] of Object.entries(mod)) { + if (!/update|patch/i.test(name) || !(schema instanceof z.ZodObject)) continue; + scanned++; + for (const [key, field] of Object.entries(schema.shape)) { + if (hasDefault(field)) + offenders.push(`${path.relative(process.cwd(), file)} ${name}.${key}`); + } + } + } + assert.ok(scanned > 20, `expected to scan the update schemas, scanned ${scanned}`); + assert.deepEqual(offenders, []); +}); diff --git a/tests/unit/proxy-egress-validate-pool-preserve.test.ts b/tests/unit/proxy-egress-validate-pool-preserve.test.ts new file mode 100644 index 0000000000..e6d3866e78 --- /dev/null +++ b/tests/unit/proxy-egress-validate-pool-preserve.test.ts @@ -0,0 +1,198 @@ +/** + * Pool validation must preserve operator/health statuses. + * + * Validating the pool rewrites only the transient active <-> error pair. A + * proxy set aside as `inactive` (operator) or marked `dead` (health) keeps its + * status even when the probe result disagrees — the probe still runs so the + * report keeps the alive/egressIp signal. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const egress = await import("../../src/lib/proxyEgress.ts"); +const { validateProxyPool, _setEgressProbeForTests, clearEgressCache } = egress; + +type Deps = NonNullable[0]>; +type ProxyRow = { id: string; type: string; host: string; port: number; status?: string | null }; +type MarkCall = { id: string; status: string }; + +const LIVE_IP = "198.51.100.21"; + +function liveOrDeadProbe( + proxyUrl: string | null +): Promise<{ ip: string | null; latencyMs: number; error?: string }> { + if (proxyUrl && proxyUrl.includes("-up.")) return Promise.resolve({ ip: LIVE_IP, latencyMs: 4 }); + return Promise.resolve({ ip: null, latencyMs: 7000, error: "timeout" }); +} + +function proxyRow(id: string, status: string | null, live: boolean): ProxyRow { + return { id, type: "http", host: `${id}-${live ? "up" : "down"}.local`, port: 8080, status }; +} + +test.afterEach(() => { + _setEgressProbeForTests(null); + clearEgressCache(); +}); + +test("validateProxyPool rewrites active/error but preserves inactive/dead", async () => { + clearEgressCache(); + _setEgressProbeForTests(liveOrDeadProbe); + + const cases: Array<{ + previous: string | null; + live: boolean; + expectWrite: string | null; + expectNewStatus: string; + expectPreserved: boolean; + }> = [ + { + previous: "active", + live: true, + expectWrite: "active", + expectNewStatus: "active", + expectPreserved: false, + }, + { + previous: "active", + live: false, + expectWrite: "error", + expectNewStatus: "error", + expectPreserved: false, + }, + { + previous: "error", + live: true, + expectWrite: "active", + expectNewStatus: "active", + expectPreserved: false, + }, + { + previous: "error", + live: false, + expectWrite: "error", + expectNewStatus: "error", + expectPreserved: false, + }, + { + previous: "inactive", + live: true, + expectWrite: null, + expectNewStatus: "inactive", + expectPreserved: true, + }, + { + previous: "inactive", + live: false, + expectWrite: null, + expectNewStatus: "inactive", + expectPreserved: true, + }, + { + previous: "dead", + live: true, + expectWrite: null, + expectNewStatus: "dead", + expectPreserved: true, + }, + { + previous: "dead", + live: false, + expectWrite: null, + expectNewStatus: "dead", + expectPreserved: true, + }, + // Mixed casing still matches, original casing echoed back. + { + previous: "Inactive", + live: true, + expectWrite: null, + expectNewStatus: "Inactive", + expectPreserved: true, + }, + // Unknown status routes to the rewritable branch and gets written. + { + previous: null, + live: true, + expectWrite: "active", + expectNewStatus: "active", + expectPreserved: false, + }, + ]; + + const listProxies: Deps["listProxies"] = async () => + cases.map((c, i) => proxyRow(`case-${i}`, c.previous, c.live)); + const calls: MarkCall[] = []; + const markStatus: Deps["markStatus"] = async (id, status) => { + calls.push({ id, status }); + }; + + const report = await validateProxyPool({ listProxies, markStatus }); + + assert.equal(report.length, cases.length); + for (let i = 0; i < cases.length; i++) { + const row = report[i]; + const c = cases[i]; + assert.equal(row.proxyId, `case-${i}`); + assert.equal(row.previousStatus, c.previous); + assert.equal(row.alive, c.live); + assert.equal(row.newStatus, c.expectNewStatus); + assert.equal(row.preserved, c.expectPreserved); + assert.equal( + row.egressIp, + c.live ? LIVE_IP : null, + "probe signal is reported even for preserved rows" + ); + } + + const expectedWrites = cases.filter((c) => c.expectWrite !== null).length; + assert.equal(calls.length, expectedWrites, "markStatus runs only for rewritable rows"); + for (let i = 0; i < cases.length; i++) { + const expected = cases[i].expectWrite; + if (expected === null) { + assert.ok( + !calls.some((c) => c.id === `case-${i}`), + `preserved row case-${i} must not be rewritten` + ); + } else { + assert.equal(calls.find((c) => c.id === `case-${i}`)?.status, expected); + } + } +}); + +test("validateProxyPool on a mixed pool leaves operator/health statuses intact", async () => { + clearEgressCache(); + _setEgressProbeForTests(liveOrDeadProbe); + + const listProxies: Deps["listProxies"] = async () => [ + proxyRow("pool-active", "active", true), + proxyRow("pool-error", "error", false), + proxyRow("pool-inactive", "inactive", true), + proxyRow("pool-dead", "dead", true), + ]; + const calls: MarkCall[] = []; + const markStatus: Deps["markStatus"] = async (id, status) => { + calls.push({ id, status }); + }; + + const report = await validateProxyPool({ listProxies, markStatus }); + + const byId = new Map(report.map((r) => [r.proxyId, r])); + assert.equal(byId.get("pool-active")?.newStatus, "active"); + assert.equal(byId.get("pool-active")?.preserved, false); + assert.equal(byId.get("pool-error")?.newStatus, "error"); + assert.equal(byId.get("pool-error")?.preserved, false); + assert.equal(byId.get("pool-inactive")?.newStatus, "inactive"); + assert.equal(byId.get("pool-inactive")?.preserved, true); + assert.equal(byId.get("pool-inactive")?.alive, true); + assert.equal(byId.get("pool-inactive")?.egressIp, LIVE_IP); + assert.equal(byId.get("pool-dead")?.newStatus, "dead"); + assert.equal(byId.get("pool-dead")?.preserved, true); + assert.equal(byId.get("pool-dead")?.alive, true); + assert.equal(byId.get("pool-dead")?.egressIp, LIVE_IP); + + assert.deepEqual( + calls.map((c) => c.id).sort(), + ["pool-active", "pool-error"], + "only rewritable rows are persisted" + ); +}); diff --git a/tests/unit/proxy-registry-manager.test.ts b/tests/unit/proxy-registry-manager.test.ts index 9d2ec9798c..e5936ce21e 100644 --- a/tests/unit/proxy-registry-manager.test.ts +++ b/tests/unit/proxy-registry-manager.test.ts @@ -14,7 +14,7 @@ test("auth-less host:port produces socks5 entry with generated name (default typ assert.equal(e.type, "socks5"); assert.equal(e.username, ""); assert.equal(e.password, ""); - assert.equal(e.status, "active"); + assert.equal("status" in e, false); assert.match(e.name, /127\.0\.0\.1:7897/); }); @@ -241,7 +241,7 @@ test("pipe-delimited minimal NAME|HOST|PORT defaults type to socks5", () => { assert.equal(errors.length, 0); assert.equal(entries.length, 1); assert.equal(entries[0].type, "socks5"); - assert.equal(entries[0].status, "active"); + assert.equal("status" in entries[0], false); }); test("pipe-delimited missing NAME produces error", () => { @@ -338,3 +338,65 @@ test("bare text with no colons or pipes produces error", () => { assert.equal(errors.length, 1); assert.equal(errors[0].reason, "bulkImportErrorMissingHost"); }); + +// ── Status is written only when the line carries one ────────────────────────── + +test("pipe-delimited line with an empty STATUS column has no status key", () => { + const { entries, errors } = parseBulkImportText("p|10.0.0.3|1080|||socks5|US||note"); + assert.equal(errors.length, 0); + assert.equal("status" in entries[0], false); +}); + +test("pipe-delimited line with STATUS=inactive keeps it", () => { + const { entries, errors } = parseBulkImportText("p|10.0.0.4|1080|||socks5|US|inactive|note"); + assert.equal(errors.length, 0); + assert.equal(entries[0].status, "inactive"); +}); + +test("pipe-delimited line with an unknown STATUS is rejected", () => { + const { entries, errors } = parseBulkImportText("p|10.0.0.5|1080|||socks5|US|foo|note"); + assert.equal(entries.length, 0); + assert.equal(errors[0].reason, "bulkImportErrorInvalidStatus"); +}); + +// ── Dashboard send payload omits a missing status ─────────────────────────── +// Mirrors the item mapping in ProxyRegistryManager (bulk import send): a line +// without a status must reach the API without a status key, so the stored one +// is left alone. JSON.stringify drops undefined values, which is what makes an +// `entry.status as ... | undefined` mapping safe to send as-is. + +function buildSendBody(entries: Array<{ [key: string]: unknown }>) { + const payload = { + items: entries.map((entry) => ({ + name: entry.name, + type: entry.type, + host: entry.host, + port: entry.port, + username: (entry.username as string) || undefined, + password: (entry.password as string) || undefined, + region: (entry.region as string) || null, + notes: (entry.notes as string) || null, + status: entry.status as "active" | "inactive" | undefined, + })), + }; + return JSON.parse(JSON.stringify(payload)) as { + items: Array<{ [key: string]: unknown }>; + }; +} + +test("send payload for a line without a status carries no status key", () => { + const { entries, errors } = parseBulkImportText("p|10.0.0.6|1080"); + assert.equal(errors.length, 0); + assert.equal("status" in entries[0], false); + const body = buildSendBody(entries); + assert.equal("status" in body.items[0], false); +}); + +test("send payload for a line with STATUS=inactive keeps inactive", () => { + const { entries, errors } = parseBulkImportText( + "p|10.0.0.7|1080|||socks5|US|inactive|note" + ); + assert.equal(errors.length, 0); + const body = buildSendBody(entries); + assert.equal(body.items[0].status, "inactive"); +}); diff --git a/tests/unit/proxy-registry-status-schema.test.ts b/tests/unit/proxy-registry-status-schema.test.ts new file mode 100644 index 0000000000..8437301df7 --- /dev/null +++ b/tests/unit/proxy-registry-status-schema.test.ts @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// zod 4 applies .default() even under .partial(), so a default status on the shared +// proxy field schema rewrote the stored status on every update or import that simply +// omitted it. These tests pin "a status is written only when the caller sends one". + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-status-schema-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const schemas = await import("../../src/shared/validation/schemas/proxy.ts"); +const statuses = await import("../../src/shared/constants/proxyRegistryStatus.ts"); +const { handleProxyUpdate } = await import("../../src/lib/api/proxyRegistryRouteHandlers.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("update schema does not invent a status the client did not send", () => { + const parsed = schemas.updateProxyRegistrySchema.parse({ id: "row-1", name: "renamed" }); + assert.equal("status" in parsed, false); +}); + +test("bulk import schema does not invent a status the client did not send", () => { + const parsed = schemas.bulkImportProxiesSchema.parse({ + items: [{ name: "n", host: "proxy.example.com", port: 8080 }], + }); + assert.equal("status" in parsed.items[0], false); +}); + +test("an explicit status is still validated against the enumeration", () => { + const bad = schemas.bulkImportProxiesSchema.safeParse({ + items: [{ name: "n", host: "proxy.example.com", port: 8080, status: "bogus" }], + }); + assert.equal(bad.success, false); + const good = schemas.updateProxyRegistrySchema.parse({ id: "row-2", status: "inactive" }); + assert.equal(good.status, "inactive"); +}); + +test("isProxyRegistryStatus accepts only the enumerated statuses", () => { + for (const value of ["active", "inactive", "dead"]) { + assert.equal(statuses.isProxyRegistryStatus(value), true, value); + } + for (const value of ["", "error", "ACTIVE", undefined, null, 1]) { + assert.equal(statuses.isProxyRegistryStatus(value), false, String(value)); + } +}); + +test("renaming a dead proxy through the update handler keeps it dead", async () => { + resetStorage(); + const created = await proxiesDb.createProxy({ + name: "before", + type: "http", + host: "10.1.0.1", + port: 8080, + }); + await proxiesDb.updateProxy(created.id, { status: "dead" }); + + const response = await handleProxyUpdate( + new Request("http://localhost/api/v1/management/proxies", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: created.id, name: "after" }), + }) + ); + + assert.equal(response.status, 200); + const row = await proxiesDb.getProxyById(created.id); + assert.equal(row?.name, "after"); + assert.equal(row?.status, "dead"); +}); + +test("creating a proxy without a status still creates it active", async () => { + resetStorage(); + const created = await proxiesDb.createProxy({ + name: "fresh", + type: "http", + host: "10.1.0.2", + port: 8080, + }); + assert.equal((await proxiesDb.getProxyById(created.id))?.status, "active"); +}); diff --git a/tests/unit/proxy-subscription-sync-ownership.test.ts b/tests/unit/proxy-subscription-sync-ownership.test.ts new file mode 100644 index 0000000000..36bcbed97b --- /dev/null +++ b/tests/unit/proxy-subscription-sync-ownership.test.ts @@ -0,0 +1,210 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; + +// A subscription refresh must not revive a node the operator or auto-disable turned off, +// and must never write a registry row it does not own (a manual proxy or another +// subscription's node sharing the same host/port/username). + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-sync-ownership-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxies = await import("../../src/lib/db/proxies.ts"); +const sub = await import("../../src/lib/proxySubscription/index.ts"); + +function reset() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function feed(name: string, port: number) { + return [ + "proxies:", + ` - name: ${name}`, + " type: http", + " server: 127.0.0.1", + ` port: ${port}`, + ].join("\n"); +} + +function startFeedServer(initialBody: string): Promise<{ + url: string; + setBody: (body: string) => void; + close: () => Promise; +}> { + let body = initialBody; + return new Promise((resolve) => { + const srv = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(body); + }); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (!addr || typeof addr === "string") throw new Error("no addr"); + resolve({ + url: `http://127.0.0.1:${addr.port}/list`, + setBody: (next) => { + body = next; + }, + close: () => new Promise((r) => srv.close(() => r())), + }); + }); + }); +} + +function insertSubscription(id: string, url: string) { + const now = new Date().toISOString(); + core + .getDbInstance() + .prepare( + `INSERT INTO proxy_subscriptions + (id, name, url, enabled, mode, rule_providers, update_interval_minutes, status, created_at, updated_at) + VALUES (?, ?, ?, 1, 'global', NULL, 60, 'empty', ?, ?)` + ) + .run(id, `sub-${id}`, url, now, now); +} + +function rowIdFor(port: number) { + const row = core + .getDbInstance() + .prepare("SELECT id FROM proxy_registry WHERE host = '127.0.0.1' AND port = ?") + .get(port) as { id: string } | undefined; + assert.ok(row, `expected a registry row on port ${port}`); + return row.id; +} + +test("a refresh keeps an owned node dead and still refreshes its name", async () => { + reset(); + const server = await startFeedServer(feed("node-v1", 18101)); + try { + insertSubscription("s1", server.url); + await sub.syncSubscription("s1"); + const id = rowIdFor(18101); + await proxies.updateProxy(id, { status: "dead" }); + + server.setBody(feed("node-v2", 18101)); + await sub.syncSubscription("s1"); + + const row = await proxies.getProxyById(id); + assert.equal(row?.status, "dead"); + assert.equal(row?.name, "node-v2"); + } finally { + await server.close(); + } +}); + +test("a refresh heals an owned node that pool validation flagged error", async () => { + reset(); + const server = await startFeedServer(feed("node", 18102)); + try { + insertSubscription("s1", server.url); + await sub.syncSubscription("s1"); + const id = rowIdFor(18102); + await proxies.updateProxy(id, { status: "error" }); + + await sub.syncSubscription("s1"); + + assert.equal((await proxies.getProxyById(id))?.status, "active"); + } finally { + await server.close(); + } +}); + +test("a new node is created active and owned by the subscription", async () => { + reset(); + const server = await startFeedServer(feed("node", 18103)); + try { + insertSubscription("s1", server.url); + const result = await sub.syncSubscription("s1"); + const row = await proxies.getProxyById(rowIdFor(18103)); + assert.equal(row?.status, "active"); + assert.equal(row?.source, "subscription"); + assert.equal(row?.subscriptionId, "s1"); + assert.equal(result.boundProxies, 1); + } finally { + await server.close(); + } +}); + +test("a manual proxy with the same tuple is not written, pooled or removed", async () => { + reset(); + const manual = await proxies.createProxy({ + name: "manual", + type: "https", + host: "127.0.0.1", + port: 18104, + password: "manual-pass", + status: "inactive", + }); + const before = await proxies.getProxyById(manual.id, { includeSecrets: true }); + const server = await startFeedServer(feed("feed-node", 18104)); + try { + insertSubscription("s1", server.url); + const result = await sub.syncSubscription("s1"); + + assert.deepEqual(await proxies.getProxyById(manual.id, { includeSecrets: true }), before); + assert.equal(result.boundProxies, 0); + const pooled = core + .getDbInstance() + .prepare("SELECT COUNT(*) AS n FROM proxy_assignments WHERE proxy_id = ?") + .get(manual.id) as { n: number }; + assert.equal(pooled.n, 0); + } finally { + await server.close(); + } +}); + +test("two subscriptions listing the same tuple: the row stays with the first one", async () => { + reset(); + const serverA = await startFeedServer(feed("from-a", 18105)); + const serverB = await startFeedServer(feed("from-b", 18105)); + try { + insertSubscription("sa", serverA.url); + insertSubscription("sb", serverB.url); + await sub.syncSubscription("sa"); + const id = rowIdFor(18105); + const before = await proxies.getProxyById(id, { includeSecrets: true }); + + const resultB = await sub.syncSubscription("sb"); + + assert.deepEqual(await proxies.getProxyById(id, { includeSecrets: true }), before); + assert.equal(before?.subscriptionId, "sa"); + assert.equal(resultB.boundProxies, 0); + } finally { + await serverA.close(); + await serverB.close(); + } +}); + +test("a refresh keeps the dead local core row a needs-core feed binds", async () => { + reset(); + const server = await startFeedServer("ss://YWVzLTI1Ni1nY206cGFzcw@203.0.113.9:8388#ss-node"); + try { + insertSubscription("s1", server.url); + core + .getDbInstance() + .prepare("UPDATE proxy_subscriptions SET local_core_endpoint = ? WHERE id = ?") + .run("socks5://127.0.0.1:18106", "s1"); + const first = await sub.syncSubscription("s1"); + const id = rowIdFor(18106); + assert.equal(first.boundProxies, 1); + assert.equal((await proxies.getProxyById(id))?.subscriptionId, "s1"); + await proxies.updateProxy(id, { status: "dead" }); + + await sub.syncSubscription("s1"); + + assert.equal((await proxies.getProxyById(id))?.status, "dead"); + } finally { + await server.close(); + } +}); diff --git a/tests/unit/proxy-upsert-preserves-status.test.ts b/tests/unit/proxy-upsert-preserves-status.test.ts new file mode 100644 index 0000000000..55c1bd4f45 --- /dev/null +++ b/tests/unit/proxy-upsert-preserves-status.test.ts @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// upsertProxy is the single writer shared by bulk import and subscription sync. A write +// that carries no valid status must leave the stored one alone, and the sync must be +// able to refuse writing a row it does not own. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-upsert-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const tuple = { type: "http", host: "10.2.0.1", port: 8080, username: "u" }; + +async function seed(status?: string) { + const created = await proxiesDb.upsertProxy({ name: "seed", ...tuple, password: "old" }); + assert.equal(created.action, "created"); + const id = created.proxy!.id; + if (status) await proxiesDb.updateProxy(id, { status }); + return id; +} + +test("a new row without a status is created active", async () => { + const id = await seed(); + assert.equal((await proxiesDb.getProxyById(id))?.status, "active"); +}); + +test("re-importing a disabled proxy without a status keeps it disabled", async () => { + const id = await seed("inactive"); + const again = await proxiesDb.upsertProxy({ name: "seed", ...tuple, password: "new" }); + assert.equal(again.action, "updated"); + const row = await proxiesDb.getProxyById(id, { includeSecrets: true }); + assert.equal(row?.status, "inactive"); + assert.equal(row?.password, "new"); +}); + +test("an explicit status on re-import is applied", async () => { + const id = await seed(); + await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: "inactive" }); + assert.equal((await proxiesDb.getProxyById(id))?.status, "inactive"); +}); + +test("an empty, unknown or undefined status on an existing row is ignored", async () => { + const id = await seed("dead"); + await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: "" }); + await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: "bogus" }); + await proxiesDb.upsertProxy({ name: "seed", ...tuple, status: undefined }); + assert.equal((await proxiesDb.getProxyById(id))?.status, "dead"); +}); + +test("a row flagged error stays in error on re-import", async () => { + const id = await seed("error"); + await proxiesDb.upsertProxy({ name: "seed", ...tuple }); + assert.equal((await proxiesDb.getProxyById(id))?.status, "error"); +}); + +test("an explicit source on import is still applied by default", async () => { + const id = await seed(); + await proxiesDb.upsertProxy({ name: "seed", ...tuple, source: "oneproxy" }); + assert.equal((await proxiesDb.getProxyById(id))?.source, "oneproxy"); +}); + +test("claimOwnership false leaves a manual row completely untouched", async () => { + const id = await seed("dead"); + const before = await proxiesDb.getProxyById(id, { includeSecrets: true }); + + const result = await proxiesDb.upsertProxy( + { + name: "feed node", + ...tuple, + type: "socks5", + password: "feed-pass", + source: "subscription", + subscriptionId: "sub-b", + }, + { claimOwnership: false } + ); + + assert.equal(result.action, "skipped"); + assert.equal(result.proxy, null); + assert.deepEqual(await proxiesDb.getProxyById(id, { includeSecrets: true }), before); +}); + +test("claimOwnership false leaves another subscription's row untouched", async () => { + const created = await proxiesDb.upsertProxy({ + name: "owned by a", + ...tuple, + source: "subscription", + subscriptionId: "sub-a", + }); + const id = created.proxy!.id; + const before = await proxiesDb.getProxyById(id, { includeSecrets: true }); + + const result = await proxiesDb.upsertProxy( + { name: "claimed by b", ...tuple, source: "subscription", subscriptionId: "sub-b" }, + { claimOwnership: false } + ); + + assert.equal(result.action, "skipped"); + assert.deepEqual(await proxiesDb.getProxyById(id, { includeSecrets: true }), before); +}); + +test("claimOwnership false still updates a row the same subscription owns", async () => { + const created = await proxiesDb.upsertProxy({ + name: "old name", + ...tuple, + source: "subscription", + subscriptionId: "sub-a", + }); + const id = created.proxy!.id; + await proxiesDb.updateProxy(id, { status: "dead" }); + + const result = await proxiesDb.upsertProxy( + { name: "new name", ...tuple, source: "subscription", subscriptionId: "sub-a" }, + { claimOwnership: false } + ); + + assert.equal(result.action, "updated"); + const row = await proxiesDb.getProxyById(id); + assert.equal(row?.name, "new name"); + assert.equal(row?.status, "dead"); +}); diff --git a/tests/unit/quota-bounded-map.test.ts b/tests/unit/quota-bounded-map.test.ts new file mode 100644 index 0000000000..5781fb5237 --- /dev/null +++ b/tests/unit/quota-bounded-map.test.ts @@ -0,0 +1,316 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { boundedMap } = await import("../../src/lib/quota/boundedMap.ts"); +const core = await import("../../src/lib/db/core.ts"); + +type LogLine = { meta: Record; message: string }; +function captureLog() { + const lines: LogLine[] = []; + return { + lines, + log: { + warn: (meta: Record, message: string) => lines.push({ meta, message }), + }, + }; +} + +test.after(() => { + core.resetDbInstance(); +}); + +// ── boundedMap primitives ──────────────────────────────────────────────────── + +test("lru: evicts the least-recently-used entry and get refreshes recency", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "lru", 0, { log }); + m.set("a", 1); + m.set("b", 2); + m.set("c", 3); + assert.equal(m.get("a"), 1); // a is now the most recent + m.set("d", 4); // evicts b + assert.equal(m.get("b"), undefined); + assert.equal(m.get("a"), 1); + assert.equal(m.size, 3); + assert.deepEqual(m.stats(), { evictions: 1, overflowInserts: 0 }); +}); + +test("ttl: reads never refresh — the oldest-written entry is evicted", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "ttl", 60_000, { log }); + m.set("a", 1, 0); + m.set("b", 2, 1); + m.set("c", 3, 2); + assert.equal(m.get("a", 3), 1); // a read, but ttl ignores recency + m.set("d", 4, 4); // evicts a (oldest write), unlike lru which would evict b + assert.equal(m.get("a", 5), undefined); + assert.equal(m.get("b", 5), 2); +}); + +test("ttl: entries expire after ttlMs; lru entries never expire", () => { + const ttl = boundedMap("t", 10, "ttl", 1000); + ttl.set("a", 1, 0); + assert.equal(ttl.get("a", 1000), 1); + assert.equal(ttl.get("a", 1001), undefined); + assert.equal(ttl.size, 0, "an expired read drops the entry"); + + const lru = boundedMap("t", 10, "lru", 1000); + lru.set("a", 1, 0); + assert.equal(lru.get("a", 10_000_000), 1); +}); + +test("ttl: expired entries are swept before any fresh entry is evicted", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "ttl", 100, { log }); + m.set("old1", 1, 0); + m.set("fresh", 2, 150); + m.set("old2", 3, 0); + m.set("new", 4, 160); // old1 + old2 expired at 160 → swept, fresh survives + assert.equal(m.get("fresh", 170), 2); + assert.equal(m.get("new", 170), 4); + assert.equal(m.stats().evictions, 0, "a sweep of expired entries is not an eviction"); +}); + +test("protected entries are never evicted: the map grows past the cap instead", () => { + const { log } = captureLog(); + const m = boundedMap<{ pin: boolean }>("t", 2, "lru", 0, { + shouldEvict: (v) => !v.pin, + log, + }); + m.set("pin1", { pin: true }); + m.set("x", { pin: false }); + m.set("y", { pin: false }); // evicts x, the only evictable entry + assert.equal(m.get("x"), undefined); + m.set("pin2", { pin: true }); // evicts y + m.set("pin3", { pin: true }); // nothing evictable → grows + assert.equal(m.size, 3); + for (const key of ["pin1", "pin2", "pin3"]) assert.deepEqual(m.get(key), { pin: true }); + assert.deepEqual(m.stats(), { evictions: 2, overflowInserts: 1 }); +}); + +test("eviction logging is aggregated and rate-limited, never one line per eviction", () => { + const { lines, log } = captureLog(); + const m = boundedMap("hot-cache", 10, "lru", 0, { log, logIntervalMs: 60_000 }); + for (let i = 0; i < 10; i++) m.set(`seed-${i}`, i, 0); + for (let i = 0; i < 1000; i++) m.set(`k-${i}`, i, 1000 + i); // 1000 evictions in ~1s + assert.equal(lines.length, 1, "first eviction logs once, the rest are aggregated"); + assert.equal(lines[0].meta.map, "hot-cache"); + assert.equal(lines[0].meta.evicted, 1); + + m.set("late", 1, 1000 + 61_000); // past the interval → one summary line + assert.equal(lines.length, 2); + assert.equal( + lines[1].meta.evicted, + 1000, + "the summary carries every eviction since the last line" + ); + assert.match(lines[1].message, /\[boundedMap:hot-cache\] cap 10 reached: evicted 1000 entries/); +}); + +test("the default logger is the project logger, not console.warn", () => { + const original = console.warn; + let consoleWarnings = 0; + console.warn = () => { + consoleWarnings += 1; + }; + try { + const m = boundedMap("console-check", 1, "lru"); + m.set("a", 1); + m.set("b", 2); + m.set("c", 3); + assert.equal(m.stats().evictions, 2); + } finally { + console.warn = original; + } + assert.equal(consoleWarnings, 0); +}); + +test("keys() iteration tolerates delete during iteration", () => { + const m = boundedMap("t", 10, "lru"); + m.set("a", 1); + m.set("b", 2); + for (const key of m.keys()) { + if (key === "a") m.delete(key); + } + assert.deepEqual([...m.keys()], ["b"]); +}); + +// ── account buckets: never fail open ───────────────────────────────────────── + +test("account buckets never evict a live saturated bucket, even past the soft cap", async () => { + const b = await import("../../src/lib/quota/accountBuckets.ts"); + b._clearBucketsForTest(); + const now = 1_800_000_000_000; + const future = new Date(now + 3_600_000).toISOString(); + try { + const total = b.ACCOUNT_BUCKETS_SOFT_CAP + 25; + for (let i = 0; i < total; i++) b.recordUsage(`conn-live-${i}`, "5h", 100, future, now); + assert.equal(b._bucketCountForTest(), total, "no saturated bucket was dropped"); + assert.equal(b.isBucketSaturated("conn-live-0", "5h", now + 1), true, "oldest still saturated"); + assert.equal(b.isBucketSaturated(`conn-live-${total - 1}`, "5h", now + 1), true); + } finally { + b._clearBucketsForTest(); + } +}); + +test("account buckets at the cap evict buckets whose reset already passed first", async () => { + const b = await import("../../src/lib/quota/accountBuckets.ts"); + b._clearBucketsForTest(); + const now = 1_800_000_000_000; + const soon = new Date(now + 1_000).toISOString(); + const later = new Date(now + 3_600_000).toISOString(); + try { + b.recordUsage("conn-stale", "5h", 100, soon, now); // resets 1s later + for (let i = 1; i < b.ACCOUNT_BUCKETS_SOFT_CAP; i++) { + b.recordUsage(`conn-keep-${i}`, "5h", 100, later, now); + } + assert.equal(b._bucketCountForTest(), b.ACCOUNT_BUCKETS_SOFT_CAP); + b.recordUsage("conn-new", "5h", 100, later, now + 5_000); // stale bucket is evictable now + assert.equal(b._bucketCountForTest(), b.ACCOUNT_BUCKETS_SOFT_CAP, "stale bucket made room"); + assert.equal(b.isBucketSaturated("conn-keep-1", "5h", now + 5_001), true); + assert.equal(b.isBucketSaturated("conn-new", "5h", now + 5_001), true); + } finally { + b._clearBucketsForTest(); + } +}); + +// ── quality tracker under real pressure ────────────────────────────────────── + +test("quality: past the cap the LRU unscored pair is dropped, semantic pins survive", async () => { + const q = await import("../../open-sse/services/routing/quality.ts"); + q.resetQualityTracker(); + const event = (provider: string, model: string) => ({ + provider, + model, + outcome: "success", + status: 200, + latencyMs: 100, + finishReason: "stop", + }); + try { + q.recordQualityEvent(event("pinned", "model")); + q.setSemanticQuality("pinned", "model", 0.9, 1); + q.recordQualityEvent(event("first", "unscored")); + for (let i = 0; i < q.QUALITY_STATES_CAP + 50; i++) { + q.recordQualityEvent(event("bulk", `m-${i}`)); + } + const snapshot = q.getQualitySnapshot(q.QUALITY_STATES_CAP * 2); + assert.equal(snapshot.length, q.QUALITY_STATES_CAP, "tracker stays at its cap"); + const pinned = snapshot.find((v) => v.provider === "pinned"); + assert.ok(pinned, "the semantic pin survived the pressure"); + assert.equal(pinned.semantic, 0.9); + assert.equal(q.getProviderQuality("first", "unscored").samples, 0, "LRU unscored pair evicted"); + assert.ok(q.getProviderQuality("bulk", `m-${q.QUALITY_STATES_CAP + 49}`).samples > 0); + } finally { + q.resetQualityTracker(); + } +}); + +// ── learned rate limits ────────────────────────────────────────────────────── + +const HEADERS = { + "x-ratelimit-limit-requests": "100", + "x-ratelimit-remaining-requests": "5", + "x-ratelimit-reset-requests": "30s", +}; + +test("learnedLimits: a deployment above the old unenforced 200 keeps every entry", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + for (let i = 0; i < 300; i++) { + rl.enableRateLimitProtection(`conn-many-${i}`); + rl.updateFromHeaders("openai", `conn-many-${i}`, HEADERS, 200); + } + assert.equal(Object.keys(rl.getLearnedLimits()).length, 300); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +test("learnedLimits: capped at MAX_LEARNED_LIMITS", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + for (let i = 0; i <= rl.MAX_LEARNED_LIMITS; i++) { + rl.enableRateLimitProtection(`conn-cap-${i}`); + rl.updateFromHeaders("openai", `conn-cap-${i}`, HEADERS, 200); + } + const learned = rl.getLearnedLimits(); + assert.equal(Object.keys(learned).length, rl.MAX_LEARNED_LIMITS); + assert.equal(learned["openai:conn-cap-0"], undefined, "oldest entry evicted"); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +test("learnedLimits persist/load round-trip", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + const settings = await import("../../src/lib/db/settings.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + rl.enableRateLimitProtection("conn-rt"); + rl.updateFromHeaders("openai", "conn-rt", HEADERS, 200); + await rl.__flushLearnedLimitsForTests(); + const raw = (await settings.getSettings())?.learnedRateLimits; + assert.equal(typeof raw, "string"); + const parsed = JSON.parse(raw as string) as Record; + assert.equal(parsed["openai:conn-rt"]?.limit, 100); + await rl.__resetRateLimitManagerForTests(); + assert.deepEqual(rl.getLearnedLimits(), {}); + await rl.initializeRateLimits(); + assert.ok(rl.getLearnedLimits()["openai:conn-rt"], "load restores the persisted entry"); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +// ── TTL caches keep their read-through behaviour ──────────────────────────── + +test("saturation cache: hits stay cached below the cap, the evicted key refetches past it", async () => { + const sat = await import("../../src/lib/quota/saturationSignals.ts"); + sat._clearSaturationCache(); + let calls = 0; + sat.__setGenericUsageFetcherForTests(async () => { + calls++; + return { percentUsed: 0.1 }; + }); + const dim = { unit: "tokens", window: "hourly" } as const; + try { + for (let i = 0; i < 600; i++) await sat.getSaturation(`conn-sat-${i}`, "some-provider", dim); + const warm = calls; + await sat.getSaturation("conn-sat-0", "some-provider", dim); + assert.equal(calls, warm, "600 entries (above the old 512 cap) are still cached"); + + for (let i = 600; i < 4097; i++) await sat.getSaturation(`conn-sat-${i}`, "some-provider", dim); + const before = calls; + // ttl policy: the read above did not refresh conn-sat-0, so as the oldest write + // it is the one entry evicted by the 4097th insert. + await sat.getSaturation("conn-sat-0", "some-provider", dim); + assert.ok(calls > before, `evicted key must refetch (calls ${before} -> ${calls})`); + } finally { + sat.__setGenericUsageFetcherForTests(null); + sat._clearSaturationCache(); + } +}); + +test("quota-fetcher cache: entries above the old 512 cap stay cached", async () => { + const g = await import("../../open-sse/services/genericQuotaFetcher.ts"); + g.__resetGenericQuotaFetcherForTests(); + let calls = 0; + g.__setGenericUsageFetcherForTests(async () => { + calls++; + return { quotas: { session: { remainingPercentage: 50, resetAt: null } } }; + }); + try { + for (let i = 0; i < 600; i++) { + await g.fetchGenericQuota(`gqf-${i}`, { id: `gqf-${i}`, provider: "openai" }); + } + const before = calls; + await g.fetchGenericQuota("gqf-0", { id: "gqf-0", provider: "openai" }); + assert.equal(calls, before, "cache hit, no refetch"); + } finally { + g.__setGenericUsageFetcherForTests(null); + g.__resetGenericQuotaFetcherForTests(); + } +}); diff --git a/tests/unit/retry-after-provenance.test.ts b/tests/unit/retry-after-provenance.test.ts new file mode 100644 index 0000000000..6b9b5d100b --- /dev/null +++ b/tests/unit/retry-after-provenance.test.ts @@ -0,0 +1,309 @@ +/** + * #13672 — Retry-After provenance on aggregated unavailable responses, opt-in via + * RETRY_AFTER_PROVENANCE_ENABLED (default off). + * + * Flag off: unavailableResponse keeps the legacy contract byte-for-byte (header always + * present, clamped to >= 1s; body is { error: { message } }) and combo drain paths only + * read structured retry fields. + * Flag on: no concrete future retry time → no Retry-After header (never a synthetic 1s, + * never "1" for an elapsed date); body carries error.retry_after_provenance; combo drain + * paths also read prose hints from JSON and plain-text bodies. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-retry-provenance-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +const FLAG = "RETRY_AFTER_PROVENANCE_ENABLED"; +delete process.env[FLAG]; + +const { unavailableResponse, parseProseRetryDelayMs, readProseRetryAfter } = + await import("../../open-sse/utils/error.ts"); +const { executeTargetAttempt } = + await import("../../open-sse/services/combo/executeTargetAttempt.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const rrState = await import("../../open-sse/services/combo/rrState.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); + +function withFlag(on: boolean, fn: () => T): T { + if (on) process.env[FLAG] = "true"; + else delete process.env[FLAG]; + return fn(); +} + +test.afterEach(() => { + delete process.env[FLAG]; +}); + +test.after(() => { + delete process.env[FLAG]; + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const HOUR = 3_600_000; +type Input = string | number | Date | null | undefined; +const NO_SIGNAL: Array<[string, Input]> = [ + ["null", null], + ["undefined", undefined], + ["zero", 0], + ["negative", -3], + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ["numeric string", "5"], + ["empty string", ""], + ["garbage string", "abc"], + ["past ISO", new Date(Date.now() - HOUR).toISOString()], + ["past Date", new Date(Date.now() - HOUR)], + ["past epoch ms", Date.now() - HOUR], +]; +const SIGNAL: Array<[string, Input]> = [ + ["seconds", 2], + ["future ISO", new Date(Date.now() + HOUR).toISOString()], + ["future Date", new Date(Date.now() + HOUR)], + ["future epoch ms", Date.now() + HOUR], +]; + +async function readBody(res: Response) { + return (await res.json()) as { error: Record }; +} + +test("flag off: unavailableResponse keeps the legacy header and body for every input", async () => { + for (const [label, input] of [...NO_SIGNAL, ...SIGNAL]) { + const res = withFlag(false, () => unavailableResponse(429, "drained", input)); + const header = res.headers.get("Retry-After"); + assert.ok(header !== null && Number(header) >= 1, `legacy header for ${label}: ${header}`); + assert.deepEqual(await readBody(res), { error: { message: "drained" } }, label); + } + const nullRes = withFlag(false, () => unavailableResponse(503, "busy", null)); + assert.equal(nullRes.headers.get("Retry-After"), "1"); + const pastRes = withFlag(false, () => + unavailableResponse(429, "drained", new Date(Date.now() - HOUR).toISOString()) + ); + assert.equal(pastRes.headers.get("Retry-After"), "1"); + await nullRes.body?.cancel(); + await pastRes.body?.cancel(); +}); + +test("flag on: no concrete future retry time omits Retry-After and says none", async () => { + for (const [label, input] of NO_SIGNAL) { + const res = withFlag(true, () => unavailableResponse(429, "drained", input)); + assert.equal(res.headers.get("Retry-After"), null, `no header for ${label}`); + const body = await readBody(res); + assert.equal(body.error.retry_after_provenance, "none", label); + assert.equal(body.error.message, "drained"); + } +}); + +test("flag on: a concrete future retry time keeps the legacy header value and says signal", async () => { + for (const [label, input] of SIGNAL) { + const legacy = withFlag(false, () => unavailableResponse(429, "drained", input)); + const res = withFlag(true, () => unavailableResponse(429, "drained", input)); + const header = res.headers.get("Retry-After"); + assert.ok(header !== null, `header for ${label}`); + assert.ok( + Math.abs(Number(header) - Number(legacy.headers.get("Retry-After"))) <= 1, + `${label}: ${header} vs legacy ${legacy.headers.get("Retry-After")}` + ); + assert.equal((await readBody(res)).error.retry_after_provenance, "signal", label); + await legacy.body?.cancel(); + } +}); + +test("parseProseRetryDelayMs reads Antigravity and generic prose, caps at 24h", () => { + assert.equal( + parseProseRetryDelayMs("Your quota will reset after 2h7m23s."), + (2 * 3600 + 7 * 60 + 23) * 1000 + ); + assert.equal(parseProseRetryDelayMs("Rate limited. Please retry after 30 seconds"), 30_000); + assert.equal(parseProseRetryDelayMs("quota will reset after 90h"), 24 * HOUR); + assert.equal(parseProseRetryDelayMs("502 Bad Gateway"), null); + assert.equal(parseProseRetryDelayMs(""), null); + assert.equal(parseProseRetryDelayMs(undefined), null); +}); + +test("readProseRetryAfter is inert with the flag off", () => { + assert.equal( + withFlag(false, () => readProseRetryAfter("retry after 30s")), + null + ); + const iso = withFlag(true, () => readProseRetryAfter("retry after 30s")); + assert.ok(iso && Math.abs(Date.parse(iso) - (Date.now() + 30_000)) < 5_000); +}); + +type Logged = { level: string; args: unknown[] }; + +function attemptFixture(response: () => Response) { + const logs: Logged[] = []; + const push = + (level: string) => + (...args: unknown[]) => + logs.push({ level, args }); + const target = { + kind: "model", + stepId: "s1", + executionKey: "ek-13672", + modelStr: "openai/gpt-4o", + provider: "openai", + providerId: null, + connectionId: "c-13672", + weight: 1, + label: null, + }; + const deps = { + strategy: "priority", + combo: { name: "t13672", models: [] }, + config: {}, + log: { info: push("info"), warn: push("warn"), debug: push("debug"), error: push("error") }, + settings: null, + resilienceSettings: { providerCooldown: { enabled: false } }, + sticky: { targets: [], messageHash: null, stuck: false }, + effectiveSessionId: null, + preScreenMap: new Map(), + quotaCutoffResetWindowConfig: {}, + maxRetries: 0, + traceInvocationId: "inv-13672", + clientRequestedStream: false, + handleSingleModelWithTimeout: async () => response(), + body: { messages: [{ role: "user", content: "hi" }] }, + startTime: Date.now(), + releaseStickyPinOnFailure() {}, + clearStaleLKGP() {}, + }; + const state = { + orderedTargets: [target], + fallbackCount: 0, + recordedAttempts: 0, + comboErrors: [], + lastError: null, + lastStatus: null, + earliestRetryAfter: null as string | null, + comboExpired: false, + exhaustedProviders: new Set(), + exhaustedConnections: new Set(), + transientRateLimitedProviders: new Set(), + abortControllers: new Map([[0, new AbortController()]]), + dispatchedTargets: new Set(), + targetFailureTrust: new Map(), + comboAttemptOrder: [], + skippedForCircuitOpen: false, + earliestCircuitOpenRetryMs: 0, + globalAttempts: 0, + observedFailure: false, + allObservedFailuresQuota: true, + observeFailure() {}, + }; + const run = () => + executeTargetAttempt({ + index: 0, + state, + deps, + targetForAttempt: target, + profile: {}, + protectedPriorityTarget: false, + } as unknown as Parameters[0]); + return { state, logs, run }; +} + +const hintLogs = (logs: Logged[], level: string) => + logs.filter((l) => l.level === level && /Retry hint unreadable/.test(String(l.args[1]))); + +const antigravityJson = () => + new Response( + JSON.stringify({ + error: { message: "You have exhausted your capacity. Your quota will reset after 2h7m23s." }, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); +const plainText429 = () => + new Response("Too many requests. Please retry after 30s", { status: 429 }); + +test("drain path: JSON prose hint feeds earliestRetryAfter only with the flag on", async () => { + const off = attemptFixture(antigravityJson); + await withFlag(false, off.run); + assert.equal(off.state.earliestRetryAfter, null, "flag off: legacy ignores prose"); + + const on = attemptFixture(antigravityJson); + await withFlag(true, on.run); + const expected = Date.now() + (2 * 3600 + 7 * 60 + 23) * 1000; + assert.ok(on.state.earliestRetryAfter, "flag on: prose hint recorded"); + assert.ok(Math.abs(Date.parse(on.state.earliestRetryAfter) - expected) < 10_000); +}); + +test("drain path: a plain-text (non-JSON) prose hint is read with the flag on", async () => { + const off = attemptFixture(plainText429); + await withFlag(false, off.run); + assert.equal(off.state.earliestRetryAfter, null); + + const on = attemptFixture(plainText429); + await withFlag(true, on.run); + assert.ok(on.state.earliestRetryAfter, "plain-text hint recorded"); + assert.ok(Math.abs(Date.parse(on.state.earliestRetryAfter) - (Date.now() + 30_000)) < 10_000); +}); + +test("drain path: an HTML 502 page logs at debug, never warn", async () => { + const html = attemptFixture( + () => new Response("

502 Bad Gateway

", { status: 502 }) + ); + await withFlag(true, html.run); + assert.equal(hintLogs(html.logs, "warn").length, 0, "no warn for an ordinary non-JSON body"); + assert.equal(hintLogs(html.logs, "debug").length, 1); + assert.equal(html.state.earliestRetryAfter, null); +}); + +test("drain path: a failed clone still warns", async () => { + const bad = new Response("plain 429", { status: 429 }); + Object.defineProperty(bad, "clone", { + value() { + throw new Error("clone boom"); + }, + }); + const fx = attemptFixture(() => bad); + await fx.run(); + assert.equal(hintLogs(fx.logs, "warn").length, 1); +}); + +test("round-robin drain path: plain-text hint reaches the final Retry-After only with the flag on", async () => { + const combo = { + name: "rr13672", + strategy: "round-robin", + config: { maxRetries: 0, disableSessionStickiness: true }, + models: [{ kind: "model", provider: "openai", providerId: "openai", model: "m", id: "rr-0" }], + }; + const dispatch = async () => { + rrState.rrCounters.clear(); + rrState.rrStickyTargets.clear(); + return (await handleComboChat({ + body: { model: combo.name, messages: [{ role: "user", content: "hi" }], stream: false }, + combo, + allCombos: [combo], + isModelAvailable: async () => true, + relayOptions: undefined, + signal: undefined, + settings: {}, + log: { info() {}, warn() {}, debug() {}, error() {} }, + handleSingleModel: async () => plainText429(), + })) as Response; + }; + + delete process.env[FLAG]; + const off = await dispatch(); + assert.equal(off.headers.get("Retry-After"), null, "flag off: no hint read, legacy JSON error"); + assert.equal((await readBody(off)).error.retry_after_provenance, undefined); + + process.env[FLAG] = "true"; + const on = await dispatch(); + const header = Number(on.headers.get("Retry-After")); + assert.ok(header >= 25 && header <= 30, `Retry-After from the plain-text hint, got ${header}`); + assert.equal((await readBody(on)).error.retry_after_provenance, "signal"); +}); diff --git a/tests/unit/route-guard-cli-tools-settings-local-only.test.ts b/tests/unit/route-guard-cli-tools-settings-local-only.test.ts new file mode 100644 index 0000000000..50bcda640a --- /dev/null +++ b/tests/unit/route-guard-cli-tools-settings-local-only.test.ts @@ -0,0 +1,102 @@ +/** + * Security regression (GHSA-35fw-cv32-2373): every /api/cli-tools/* route whose + * handler reaches a child-process spawn must be classified LOCAL_ONLY so loopback + * enforcement runs unconditionally before any auth check. + * + * 13 routes call getCliRuntimeStatus(toolId) directly from their exported GET + * handler (src/shared/services/cliRuntime.ts): getCliRuntimeStatus -> + * locateCommandCandidate -> locateCommand -> runProcess("sh", ["-c", + * 'command -v -- "$1"', ...]) -> spawn(). /api/cli-tools/detect reaches the same + * class via detectAllTools() -> execFile(binary, ["--version"]) + execFile("which") + * (src/lib/cli-helper/tool-detector.ts). Their six siblings (omp, letta, + * grok-build, forge, jcode, qwen -settings + runtime/) call the SAME helper and + * were already LOCAL_ONLY; these 14 were only behind Tier 3 MANAGEMENT, which + * requireManagementAuth() waives whenever requireLogin=false (including the + * fresh-install window before a password is set). + * + * Classifying them LOCAL_ONLY closes the remote-spawn vector: a non-loopback / + * non-LAN caller — with or without a leaked JWT over a Cloudflared/Ngrok tunnel — + * cannot trigger process spawning or enumerate the host's CLI inventory. + * Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyBypassableByManageScope, + isLocalOnlyPath, +} from "../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_PREFIXES } from "../../src/shared/constants/spawnCapablePrefixes.ts"; +import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../scripts/check/check-route-guard-membership.ts"; + +/** The 14 cli-tools routes that reach a spawn (GHSA-35fw-cv32-2373). */ +const SPAWNING_CLI_TOOLS_ROUTES: ReadonlyArray = [ + "/api/cli-tools/all-statuses", + "/api/cli-tools/claude-settings", + "/api/cli-tools/cline-settings", + "/api/cli-tools/codewhale-settings", + "/api/cli-tools/codex-settings", + "/api/cli-tools/crush-settings", + "/api/cli-tools/deepseek-tui-settings", + "/api/cli-tools/detect", + "/api/cli-tools/droid-settings", + "/api/cli-tools/kilo-settings", + "/api/cli-tools/openclaw-settings", + "/api/cli-tools/pi-settings", + "/api/cli-tools/smelt-settings", + "/api/cli-tools/status", +]; + +for (const route of SPAWNING_CLI_TOOLS_ROUTES) { + test(`GHSA-35fw: ${route} is LOCAL_ONLY (reaches spawn via getCliRuntimeStatus/detectAllTools)`, () => { + assert.equal(isLocalOnlyPath(route), true); + }); + + test(`GHSA-35fw: ${route}/ (trailing slash) is LOCAL_ONLY`, () => { + assert.equal(isLocalOnlyPath(`${route}/`), true); + }); + + test(`GHSA-35fw: ${route} cannot be opened through the manage-scope bypass`, () => { + // Mirror in SPAWN_CAPABLE_PREFIXES: the zod schema rejects the prefix at + // PATCH /api/settings time and the runtime predicate refuses a malformed row. + assert.ok( + SPAWN_CAPABLE_PREFIXES.includes(route), + `${route} must be listed in SPAWN_CAPABLE_PREFIXES` + ); + assert.equal(isLocalOnlyBypassableByManageScope(route), false); + }); + + test(`GHSA-35fw: the spawn-capable route audit enumerates ${route}`, () => { + const root = `src/app${route}`; + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes(root), + `${root} must be listed in SPAWN_CAPABLE_ROUTE_ROOTS so the 6A.8 gate enforces it` + ); + }); +} + +test("GHSA-35fw: the already-gated cli-tools siblings stay LOCAL_ONLY", () => { + // Guards against a refactor dropping the precedent these entries follow. + assert.equal(isLocalOnlyPath("/api/cli-tools/omp-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/letta-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/grok-build-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/forge-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/jcode-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/qwen-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true); +}); + +test("GHSA-35fw: non-spawning cli-tools routes are NOT over-gated (no blanket prefix)", () => { + // These are legitimate remote-dashboard routes: file/config reads and writes + // with no child-process reach. A blanket "/api/cli-tools/" prefix would break + // every tunnel-served dashboard, so the fix is 14 exact entries, not one. + assert.equal(isLocalOnlyPath("/api/cli-tools/apply"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/backups"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/config"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/guide-settings/claude"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/hermes-agent-settings"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/logs"), false); + // "/api/cli-tools/openclaw-settings" must not swallow the sibling + // "/api/cli-tools/openclaw/auto-order" (different segment, no spawn). + assert.equal(isLocalOnlyPath("/api/cli-tools/openclaw/auto-order"), false); +}); diff --git a/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts b/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts index aef5669d9b..04c9871fdb 100644 --- a/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts +++ b/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts @@ -42,7 +42,10 @@ test("sibling cli-tools spawn-capable settings routes stay LOCAL_ONLY", () => { test("non-spawning cli-tools routes are NOT over-gated by this entry", () => { // The new prefixes must not accidentally widen to the whole /api/cli-tools/ subtree, - // which remote dashboards legitimately use. - assert.equal(isLocalOnlyPath("/api/cli-tools/all-statuses"), false); + // which remote dashboards legitimately use. (/api/cli-tools/all-statuses used to be + // the negative control here, but it calls getCliRuntimeStatus() too and became + // LOCAL_ONLY under GHSA-35fw-cv32-2373 — see + // route-guard-cli-tools-settings-local-only.test.ts.) assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/config"), false); }); diff --git a/tests/unit/route-guard-grok-build-settings-local-only.test.ts b/tests/unit/route-guard-grok-build-settings-local-only.test.ts index b02348f151..72da038193 100644 --- a/tests/unit/route-guard-grok-build-settings-local-only.test.ts +++ b/tests/unit/route-guard-grok-build-settings-local-only.test.ts @@ -33,7 +33,10 @@ test("sibling cli-tools spawn-capable settings routes stay LOCAL_ONLY", () => { test("non-spawning cli-tools routes are NOT over-gated by this entry", () => { // The new prefix must not accidentally widen to the whole /api/cli-tools/ subtree, - // which remote dashboards legitimately use. - assert.equal(isLocalOnlyPath("/api/cli-tools/all-statuses"), false); + // which remote dashboards legitimately use. (/api/cli-tools/all-statuses used to be + // the negative control here, but it calls getCliRuntimeStatus() too and became + // LOCAL_ONLY under GHSA-35fw-cv32-2373 — see + // route-guard-cli-tools-settings-local-only.test.ts.) assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/config"), false); }); diff --git a/tests/unit/route-guard-skills-execute-local-only.test.ts b/tests/unit/route-guard-skills-execute-local-only.test.ts new file mode 100644 index 0000000000..b08a8c30fa --- /dev/null +++ b/tests/unit/route-guard-skills-execute-local-only.test.ts @@ -0,0 +1,74 @@ +/** + * Security regression (GHSA-jx89-f37j-pq89): /api/skills/install and + * /api/skills/executions must be classified LOCAL_ONLY so loopback enforcement + * runs unconditionally before any auth check. + * + * POST /api/skills/install stores the request's `handlerCode` string verbatim as + * the skill's `handler` with no allowlist (src/app/api/skills/install/route.ts). + * POST /api/skills/executions then calls skillExecutor.execute(), whose handler + * resolution (src/lib/skills/executor.ts) falls through to the built-in table — + * so a handler string that equals `execute_command` or `eval_code` runs the real + * built-in (src/lib/skills/builtins.ts), which reaches + * childProcess.spawn() in src/lib/skills/sandbox.ts. The + * sandbox is a hardened docker/podman container, but the spawn is real and + * transitive: the 6A.8 source-scan gate only greps route.ts, so it cannot see it. + * + * Both routes were only behind requireManagementAuth() / isAuthenticated(), + * which waive auth whenever requireLogin=false — the identical class already + * closed for /api/acp/agents (GHSA-hf57-cqmx-p4gr) and /api/skills/collect/. + * Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyBypassableByManageScope, + isLocalOnlyPath, +} from "../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_PREFIXES } from "../../src/shared/constants/spawnCapablePrefixes.ts"; +import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../scripts/check/check-route-guard-membership.ts"; + +test("GHSA-jx89: /api/skills/install is LOCAL_ONLY (registers a handler that can alias execute_command)", () => { + assert.equal(isLocalOnlyPath("/api/skills/install"), true); +}); + +test("GHSA-jx89: /api/skills/install with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/skills/install/"), true); +}); + +test("GHSA-jx89: /api/skills/executions is LOCAL_ONLY (skillExecutor.execute reaches sandbox spawn)", () => { + assert.equal(isLocalOnlyPath("/api/skills/executions"), true); +}); + +test("GHSA-jx89: /api/skills/executions with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/skills/executions/"), true); +}); + +test("GHSA-jx89: neither skills execution route can be opened through the manage-scope bypass", () => { + for (const route of ["/api/skills/install", "/api/skills/executions"]) { + assert.ok( + SPAWN_CAPABLE_PREFIXES.includes(route), + `${route} must be listed in SPAWN_CAPABLE_PREFIXES` + ); + assert.equal(isLocalOnlyBypassableByManageScope(route), false); + } +}); + +test("GHSA-jx89: the spawn-capable route audit enumerates both skills execution routes", () => { + assert.ok(SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/install")); + assert.ok(SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/executions")); +}); + +test("GHSA-jx89: the existing /api/skills/collect/ gate is untouched", () => { + assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true); +}); + +test("GHSA-jx89: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => { + // Registry listing / delete, marketplace and skillssh do not reach the sandbox + // spawn: they stay on requireManagementAuth() and must remain tunnel-reachable. + assert.equal(isLocalOnlyPath("/api/skills"), false); + assert.equal(isLocalOnlyPath("/api/skills/"), false); + assert.equal(isLocalOnlyPath("/api/skills/some-id"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/skillssh/install"), false); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 2f6a383044..00a6e062cf 100644 --- a/tests/unit/server-owned-tool-loop-flag.test.ts +++ b/tests/unit/server-owned-tool-loop-flag.test.ts @@ -68,7 +68,7 @@ describe("isServerOwnedToolLoopEnabled wrapper", () => { describe("feature-flags-settings count update", () => { it("flag count matches updated expected value", () => { - assert.equal(FEATURE_FLAG_DEFINITIONS.length, 55); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 60); }); }); diff --git a/tests/unit/settings-socks-flag-reader.test.ts b/tests/unit/settings-socks-flag-reader.test.ts new file mode 100644 index 0000000000..9c36410c3e --- /dev/null +++ b/tests/unit/settings-socks-flag-reader.test.ts @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-socks-flag-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { isSocks5ProxyEnabled } = await import("../../open-sse/utils/proxyDispatcher.ts"); +const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts"); +const proxyRoute = await import("../../src/app/api/settings/proxy/route.ts"); + +// ENABLE_SOCKS5_PROXY is opt-out: only an explicit falsey value disables SOCKS5. +const MATRIX: Array<[string | undefined, boolean]> = [ + [undefined, true], + ["", true], + ["true", true], + ["1", true], + ["yes", true], + ["false", false], + ["0", false], + ["no", false], + ["off", false], + [" OFF ", false], + ["False", false], +]; + +async function withSocksFlag(value: string | undefined, fn: () => Promise | T): Promise { + const previous = process.env.ENABLE_SOCKS5_PROXY; + if (value === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = value; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = previous; + } +} + +function putProxy(body: unknown) { + return proxyRoute.PUT( + new Request("http://localhost/api/settings/proxy", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + ); +} + +test.before(() => { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("flag reader honors the opt-out matrix (unset defaults ON)", async () => { + for (const [value, expected] of MATRIX) { + await withSocksFlag(value, () => { + assert.equal(isSocks5ProxyEnabled(), expected, `ENABLE_SOCKS5_PROXY=${String(value)}`); + }); + } +}); + +test("GET /api/settings/proxies reports socks5Enabled exactly as the flag reader", async () => { + for (const [value, expected] of MATRIX) { + await withSocksFlag(value, async () => { + const response = await proxiesRoute.GET(new Request("http://localhost/api/settings/proxies")); + assert.equal(response.status, 200); + const body = (await response.json()) as { socks5Enabled: boolean }; + assert.equal(body.socks5Enabled, expected, `ENABLE_SOCKS5_PROXY=${String(value)}`); + }); + } +}); + +test("PUT /api/settings/proxy accepts or rejects socks5 following the flag", async () => { + for (const [value, expected] of MATRIX) { + await withSocksFlag(value, async () => { + const response = await putProxy({ + level: "global", + proxy: { type: "socks5", host: "127.0.0.1", port: 1080 }, + }); + const body = (await response.json()) as { error?: { message?: string } }; + if (expected) { + assert.equal(response.status, 200, `ENABLE_SOCKS5_PROXY=${String(value)}`); + } else { + assert.equal(response.status, 400, `ENABLE_SOCKS5_PROXY=${String(value)}`); + assert.match(body.error?.message ?? "", /SOCKS5 proxy is disabled/); + } + }); + } +}); diff --git a/tests/unit/socks-userinfo-decode-guard.test.ts b/tests/unit/socks-userinfo-decode-guard.test.ts new file mode 100644 index 0000000000..57fbcde169 --- /dev/null +++ b/tests/unit/socks-userinfo-decode-guard.test.ts @@ -0,0 +1,281 @@ +import { describe, it, before, after, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; +import { request } from "undici"; +import { decodeUserinfo } from "../../src/shared/utils/decodeUserinfo.ts"; +import { + __getSocksOptionsForTest, + clearDispatcherCache, + createProxyDispatcher, +} from "../../open-sse/utils/proxyDispatcher.ts"; +import { coerceProxyPayload } from "../../src/lib/db/proxies/mappers.ts"; +import { parseSubscription } from "../../src/lib/proxySubscription/parse.ts"; + +// ── local fixtures ────────────────────────────────────────────────────────── + +type Closeable = { port: number; close: () => Promise }; + +function trackSockets(server: net.Server) { + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + return () => + new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }); +} + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +async function startTarget(): Promise { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("target-ok"); + }); + const close = trackSockets(server); + return { port: await listen(server), close }; +} + +/** HTTP CONNECT proxy that records every Proxy-Authorization header it receives. */ +async function startConnectProxy(seen: Array): Promise { + const server = http.createServer((_req, res) => { + res.writeHead(405); + res.end(); + }); + server.on("connect", (req, clientSocket: net.Socket, head: Buffer) => { + seen.push(req.headers["proxy-authorization"]); + const [host, port] = String(req.url).split(":"); + const upstream = net.connect(Number(port), host, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => upstream.destroy()); + }); + const close = trackSockets(server); + return { port: await listen(server), close }; +} + +/** Minimal SOCKS5 server (RFC 1928 + RFC 1929 user/pass) that records the credentials. */ +async function startSocks5Proxy(seen: Array<{ user: string; pass: string }>): Promise { + const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + let stage: "greeting" | "auth" | "request" | "piped" = "greeting"; + socket.on("error", () => socket.destroy()); + socket.on("data", (chunk: Buffer) => { + if (stage === "piped") return; + buffer = Buffer.concat([buffer, chunk]); + if (stage === "greeting") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return; + buffer = buffer.subarray(2 + buffer[1]); + socket.write(Buffer.from([0x05, 0x02])); // username/password + stage = "auth"; + } + if (stage === "auth") { + if (buffer.length < 2) return; + const ulen = buffer[1]; + if (buffer.length < 3 + ulen) return; + const plen = buffer[2 + ulen]; + if (buffer.length < 3 + ulen + plen) return; + seen.push({ + user: buffer.subarray(2, 2 + ulen).toString("utf8"), + pass: buffer.subarray(3 + ulen, 3 + ulen + plen).toString("utf8"), + }); + buffer = buffer.subarray(3 + ulen + plen); + socket.write(Buffer.from([0x01, 0x00])); + stage = "request"; + } + if (stage === "request") { + if (buffer.length < 5) return; + const atyp = buffer[3]; + let host: string; + let offset: number; + if (atyp === 0x01) { + if (buffer.length < 10) return; + host = Array.from(buffer.subarray(4, 8)).join("."); + offset = 8; + } else if (atyp === 0x03) { + const len = buffer[4]; + if (buffer.length < 7 + len) return; + host = buffer.subarray(5, 5 + len).toString("utf8"); + offset = 5 + len; + } else { + socket.destroy(); + return; + } + const port = buffer.readUInt16BE(offset); + const rest = buffer.subarray(offset + 2); + stage = "piped"; + const upstream = net.connect(port, host, () => { + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + if (rest.length > 0) upstream.write(rest); + upstream.pipe(socket); + socket.pipe(upstream); + }); + upstream.on("error", () => socket.destroy()); + } + }); + }); + const close = trackSockets(server); + return { port: await listen(server), close }; +} + +function basic(user: string, pass: string) { + return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`; +} + +async function fetchThrough(proxyUrl: string, targetPort: number) { + const dispatcher = createProxyDispatcher(proxyUrl); + const response = await request(`http://127.0.0.1:${targetPort}/`, { dispatcher }); + const text = await response.body.text(); + return { status: response.statusCode, text }; +} + +// ── decodeUserinfo ────────────────────────────────────────────────────────── + +describe("decodeUserinfo", () => { + it("decodes correctly encoded values as before", () => { + assert.equal(decodeUserinfo("user%40name"), "user@name"); + assert.equal(decodeUserinfo("p%3Ass"), "p:ss"); + }); + + it("falls back to the raw value when the value holds a literal percent", () => { + assert.equal(decodeUserinfo("user%name"), "user%name"); + assert.equal(decodeUserinfo("pa%ss"), "pa%ss"); + assert.equal(decodeUserinfo("100%"), "100%"); + }); +}); + +// ── real dispatchers against local proxies ────────────────────────────────── + +describe("HTTP proxy dispatcher credentials (real ProxyAgent + local CONNECT proxy)", () => { + const seen: Array = []; + let target: Closeable; + let proxy: Closeable; + + before(async () => { + target = await startTarget(); + proxy = await startConnectProxy(seen); + }); + afterEach(() => { + seen.length = 0; + clearDispatcherCache(); + }); + after(async () => { + clearDispatcherCache(); + await proxy.close(); + await target.close(); + }); + + it("a literal percent in the password reaches the proxy verbatim", async () => { + const result = await fetchThrough(`http://user:pa%ss@127.0.0.1:${proxy.port}`, target.port); + assert.deepEqual(result, { status: 200, text: "target-ok" }); + assert.deepEqual(seen, [basic("user", "pa%ss")]); + }); + + it("a literal percent in the username reaches the proxy verbatim", async () => { + const result = await fetchThrough(`http://us%er:secret@127.0.0.1:${proxy.port}`, target.port); + assert.equal(result.status, 200); + assert.deepEqual(seen, [basic("us%er", "secret")]); + }); + + it("correctly encoded credentials are decoded exactly as undici did before", async () => { + const result = await fetchThrough( + `http://user%40corp:p%3Ass@127.0.0.1:${proxy.port}`, + target.port + ); + assert.equal(result.status, 200); + assert.deepEqual(seen, [basic("user@corp", "p:ss")]); + }); + + it("username without password keeps undici's `user:` shape", async () => { + const result = await fetchThrough(`http://onlyuser@127.0.0.1:${proxy.port}`, target.port); + assert.equal(result.status, 200); + assert.deepEqual(seen, [basic("onlyuser", "")]); + }); + + it("no userinfo sends no Proxy-Authorization header", async () => { + const result = await fetchThrough(`http://127.0.0.1:${proxy.port}`, target.port); + assert.equal(result.status, 200); + assert.deepEqual(seen, [undefined]); + }); +}); + +describe("SOCKS5 proxy dispatcher credentials (real socks dispatcher + local SOCKS5 server)", () => { + const seen: Array<{ user: string; pass: string }> = []; + let target: Closeable; + let proxy: Closeable; + + before(async () => { + target = await startTarget(); + proxy = await startSocks5Proxy(seen); + }); + afterEach(() => { + seen.length = 0; + clearDispatcherCache(); + }); + after(async () => { + clearDispatcherCache(); + await proxy.close(); + await target.close(); + }); + + it("a literal percent in SOCKS5 credentials reaches the proxy verbatim", async () => { + const result = await fetchThrough( + `socks5://user%name:pa%ss@127.0.0.1:${proxy.port}`, + target.port + ); + assert.deepEqual(result, { status: 200, text: "target-ok" }); + assert.deepEqual(seen, [{ user: "user%name", pass: "pa%ss" }]); + }); + + it("correctly encoded SOCKS5 credentials are decoded as before", async () => { + const result = await fetchThrough( + `socks5://user%40corp:p%3Ass@127.0.0.1:${proxy.port}`, + target.port + ); + assert.equal(result.status, 200); + assert.deepEqual(seen, [{ user: "user@corp", pass: "p:ss" }]); + }); + + it("the test accessor mirrors the dispatcher", () => { + const opts = __getSocksOptionsForTest("socks5://user%name:pa%ss@host:1080"); + assert.equal(opts.userId, "user%name"); + assert.equal(opts.password, "pa%ss"); + }); +}); + +// ── other userinfo parse sites ────────────────────────────────────────────── + +describe("other proxy URL parse sites keep a literal percent", () => { + it("coerceProxyPayload (proxy registry mapper)", () => { + const payload = coerceProxyPayload("http://user:pa%ss@proxy.local:3128", "legacy"); + assert.ok(payload, "a literal percent must not drop the whole proxy entry"); + assert.equal(payload.username, "user"); + assert.equal(payload.password, "pa%ss"); + const encoded = coerceProxyPayload("http://user%40corp:p%3Ass@proxy.local:3128", "legacy"); + assert.equal(encoded?.username, "user@corp"); + assert.equal(encoded?.password, "p:ss"); + }); + + it("parseSubscription (proxy subscription URI list)", () => { + const parsed = parseSubscription( + ["http://user:pa%ss@proxy-a.example:3128#a", "socks5://us%er:x@proxy-b.example:1080#b"].join( + "\n" + ) + ); + const byName = Object.fromEntries(parsed.nodes.map((node) => [node.name, node])); + assert.equal(byName.a?.password, "pa%ss"); + assert.equal(byName.b?.username, "us%er"); + }); +}); diff --git a/tests/unit/stream-recovery-toolcall.test.ts b/tests/unit/stream-recovery-toolcall.test.ts index 7f3a765343..1cb118e853 100644 --- a/tests/unit/stream-recovery-toolcall.test.ts +++ b/tests/unit/stream-recovery-toolcall.test.ts @@ -1,10 +1,12 @@ -import { describe, it } from "node:test"; +import { after, afterEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { createRecoverableStream, TruncatedStreamError, scanOpenAiSseText, } from "../../open-sse/services/streamRecovery.ts"; +import { STREAM_RECOVERY } from "../../open-sse/config/constants.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; const enc = new TextEncoder(); @@ -176,3 +178,171 @@ describe("stream recovery does not duplicate an in-flight tool call", () => { assert.equal(scanFull.sawToolCallInFlight, false); }); }); + +// ── STREAM_RECOVERY_TOOLCALL_ORDER_FIX (opt-in, default off) ────────────────── +// Both sides run through the real createRecoverableStream and the real feature-flag +// lookup (env source); the flag is read lazily inside the stream, so it is set before the +// stream is drained. + +const ORDER_FIX_FLAG = "STREAM_RECOVERY_TOOLCALL_ORDER_FIX"; +const ORIGINAL_ORDER_FIX_FLAG = process.env[ORDER_FIX_FLAG]; + +function setOrderFix(on: boolean) { + if (on) process.env[ORDER_FIX_FLAG] = "true"; + else delete process.env[ORDER_FIX_FLAG]; +} + +afterEach(() => { + if (ORIGINAL_ORDER_FIX_FLAG === undefined) delete process.env[ORDER_FIX_FLAG]; + else process.env[ORDER_FIX_FLAG] = ORIGINAL_ORDER_FIX_FLAG; +}); + +after(() => { + resetDbInstance(); +}); + +// Deliver each chunk on its own read, then cut with a retryable truncation. +function makeChunkedStream(chunks: string[]): ReadableStream { + let n = 0; + return new ReadableStream({ + pull(c) { + if (n < chunks.length) { + c.enqueue(enc.encode(chunks[n++])); + return; + } + c.error(new TruncatedStreamError()); + }, + }); +} + +function streamOf(body: string): ReadableStream { + return new ReadableStream({ + start(c) { + c.enqueue(enc.encode(body)); + c.close(); + }, + }); +} + +async function drainAll(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + try { + for (;;) { + const r = await reader.read(); + if (r.done) break; + if (r.value) out += decoder.decode(r.value, { stream: true }); + } + } catch { + // a refused continuation surfaces the original truncation error + } + return out; +} + +async function countContinuations( + chunks: string[], + continuation: () => ReadableStream | null = () => null +): Promise<{ calls: number; out: string }> { + let calls = 0; + const wrapped = createRecoverableStream(makeChunkedStream(chunks), async () => null, { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + calls += 1; + return continuation(); + }, + }); + const out = await drainAll(wrapped); + return { calls, out }; +} + +const TEXT = 'data: {"choices":[{"index":0,"delta":{"content":"Let me check that. "}}]}\n'; +const CALL_COMPLETE = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c1","function":{"name":"f","arguments":"{}"}}]}}]}\n'; +const CALL_PARTIAL = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c2","function":{"name":"f"}}]}}]}\n'; +const FINISH_TOOL_CALLS = + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n'; + +// The three tool-call shapes a cut can land on. +const COALESCED_FINISHED_CALL = [TEXT + CALL_COMPLETE + FINISH_TOOL_CALLS + "\n"]; +const COALESCED_FINISHED_THEN_PARTIAL = [ + TEXT + CALL_COMPLETE + FINISH_TOOL_CALLS + CALL_PARTIAL + "\n", +]; +const SPLIT_CALL_THEN_FINISH = [TEXT + CALL_PARTIAL + "\n", FINISH_TOOL_CALLS + "\n"]; + +// A reasoning-only "stop" (the hallucinatedEmptyStop recovery path) whose every +// continuation comes back empty and non-terminal. +const REASONING_ONLY_STOP = [ + 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n' + + 'data: {"choices":[{"delta":{"reasoning_content":"the model thinks it through"}}]}\n\n' + + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', +]; +const emptyNonTerminal = () => streamOf('data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n'); + +describe("STREAM_RECOVERY_TOOLCALL_ORDER_FIX off keeps the release behavior", () => { + it("resumes after a coalesced finished call and after a finished call followed by a partial one", async () => { + setOrderFix(false); + assert.equal((await countContinuations(COALESCED_FINISHED_CALL)).calls, 1); + assert.equal((await countContinuations(COALESCED_FINISHED_THEN_PARTIAL)).calls, 1); + }); + + it("stays latched when the call and its finish arrive in separate batches", async () => { + setOrderFix(false); + assert.equal((await countContinuations(SPLIT_CALL_THEN_FINISH)).calls, 0); + }); + + it("empty continuations still spend the whole continuation budget", async () => { + setOrderFix(false); + const { calls, out } = await countContinuations(REASONING_ONLY_STOP, emptyNonTerminal); + assert.equal(calls, STREAM_RECOVERY.EARLY_RETRY_MAX); + assert.match(out, /\[DONE\]/); + }); +}); + +describe("STREAM_RECOVERY_TOOLCALL_ORDER_FIX on makes continuation tool-call safe", () => { + it("never resumes a finished call followed by a partial call in the same batch", async () => { + setOrderFix(true); + assert.equal((await countContinuations(COALESCED_FINISHED_THEN_PARTIAL)).calls, 0); + }); + + it("never resumes a turn that already finished with finish_reason tool_calls", async () => { + setOrderFix(true); + const coalesced = await countContinuations(COALESCED_FINISHED_CALL); + assert.equal(coalesced.calls, 0); + assert.doesNotMatch(coalesced.out, /"finish_reason":"stop"/); + // The latch is never re-armed by a later finish: the split shape stays refused too. + assert.equal((await countContinuations(SPLIT_CALL_THEN_FINISH)).calls, 0); + }); + + it("still resumes a plain-text truncation", async () => { + setOrderFix(true); + let calls = 0; + const wrapped = createRecoverableStream( + makeStream('data: {"choices":[{"index":0,"delta":{"content":"hello brave new "}}]}\n\n'), + async () => null, + { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + calls += 1; + return streamOf( + 'data: {"choices":[{"index":0,"delta":{"content":"hello brave new world"}}]}\n' + + "data: [DONE]\n\n" + ); + }, + } + ); + const out = await drainAll(wrapped); + assert.equal(calls, 1); + assert.match(out, /world/); + }); + + it("stops after one empty continuation instead of spending the whole budget", async () => { + setOrderFix(true); + const { calls, out } = await countContinuations(REASONING_ONLY_STOP, emptyNonTerminal); + assert.equal(calls, 1); + assert.match(out, /\[DONE\]/, "the client still gets a clean terminal"); + }); +}); diff --git a/tests/unit/stream-recovery-trace-logging.test.ts b/tests/unit/stream-recovery-trace-logging.test.ts new file mode 100644 index 0000000000..28e815f677 --- /dev/null +++ b/tests/unit/stream-recovery-trace-logging.test.ts @@ -0,0 +1,189 @@ +// Mid-stream continuation log wiring: buildContinuationLogHooks (the exact hooks chatCore +// spreads into createRecoverableStream) driven through the real recoverable stream. Warn is +// reserved for the attempt line (release wording) and for a recovery that gives up; every +// other outcome is debug, and a healthy or tool-call stream adds no line at all. +import { after, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + createRecoverableStream, + TruncatedStreamError, + type ContinuationOutcome, +} from "../../open-sse/services/streamRecovery.ts"; +import { + buildContinuationLogHooks, + formatContinuationOutcome, +} from "../../open-sse/handlers/chatCore/recoveryTraceLogging.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +after(() => { + resetDbInstance(); +}); + +const enc = new TextEncoder(); + +function steppingClock() { + let t = 0; + return () => (t += 1000); +} + +function streamFrom(chunks: string[], truncate = false) { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(enc.encode(chunks[i++])); + return; + } + if (truncate) controller.error(new TruncatedStreamError()); + else controller.close(); + }, + }); +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const dec = new TextDecoder(); + let out = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) out += dec.decode(value, { stream: true }); + } + } catch { + // a refused continuation surfaces the original truncation + } + return out; +} + +const ROLE = 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n'; +const DONE = "data: [DONE]\n\n"; +const content = (s: string) => `data: {"choices":[{"delta":{"content":${JSON.stringify(s)}}}]}\n\n`; +const TOOL_CALL = + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"c1","function":{"name":"f"}}]}}]}\n\n'; +const FINISH_TOOL_CALLS = 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n'; + +function capture() { + const warn: string[] = []; + const debug: string[] = []; + const log = { + warn: (tag: string, msg: string) => warn.push(`${tag} ${msg}`), + debug: (tag: string, msg: string) => debug.push(`${tag} ${msg}`), + }; + return { warn, debug, hooks: buildContinuationLogHooks(log) }; +} + +async function run( + initial: ReadableStream, + continueStream: () => Promise | null>, + hooks: ReturnType +) { + return drain( + createRecoverableStream(initial, async () => null, { + finalize: () => {}, + now: steppingClock(), + continueStream, + ...hooks, + }) + ); +} + +test("a stitched continuation warns once with the release attempt wording, outcome at debug", async () => { + const { warn, debug, hooks } = capture(); + const out = await run( + streamFrom([ROLE, content("Hello there world")]), + async () => streamFrom([ROLE, content("there world, nice to meet you!"), DONE]), + hooks + ); + assert.match(out, /nice to meet you!/); + assert.deepEqual(warn, ["STREAM_RECOVERY mid-stream continuation attempt 1/4"]); + assert.deepEqual(debug, [ + "STREAM_RECOVERY mid-stream continuation attempt 1/4 outcome=suffix suffixChars=19", + ]); +}); + +test("a streamed tool call that ends nominally logs nothing", async () => { + const { warn, debug, hooks } = capture(); + await run(streamFrom([ROLE, TOOL_CALL, FINISH_TOOL_CALLS, DONE]), async () => null, hooks); + assert.deepEqual(warn, []); + assert.deepEqual(debug, []); +}); + +test("a cut refused because a tool call is in flight is debug only", async () => { + const { warn, debug, hooks } = capture(); + let calls = 0; + await run( + streamFrom([ROLE, content("Let me check. "), TOOL_CALL], true), + async () => { + calls += 1; + return null; + }, + hooks + ); + assert.equal(calls, 0); + assert.deepEqual(warn, []); + assert.deepEqual(debug, [ + "STREAM_RECOVERY mid-stream continuation attempt 0/4 outcome=refused reason=tool-call", + ]); +}); + +test("a spent continuation budget warns that the recovery gave up", async () => { + const { warn, debug, hooks } = capture(); + let calls = 0; + await run( + streamFrom([ROLE, content("Hello there world")], true), + async () => { + calls += 1; + // Always overlaps and never terminates: every attempt truncates again. + return streamFrom([ROLE, content("there world")]); + }, + hooks + ); + assert.equal(calls, 4); + assert.deepEqual(warn, [ + "STREAM_RECOVERY mid-stream continuation attempt 1/4", + "STREAM_RECOVERY mid-stream continuation attempt 2/4", + "STREAM_RECOVERY mid-stream continuation attempt 3/4", + "STREAM_RECOVERY mid-stream continuation attempt 4/4", + "STREAM_RECOVERY mid-stream continuation attempt 4/4 outcome=refused reason=budget", + ]); + assert.deepEqual(debug, []); +}); + +test("a continuation request that returns no stream warns that the recovery gave up", async () => { + const { warn, debug, hooks } = capture(); + await run(streamFrom([ROLE, content("Hello there world")], true), async () => null, hooks); + assert.deepEqual(warn, [ + "STREAM_RECOVERY mid-stream continuation attempt 1/4", + "STREAM_RECOVERY mid-stream continuation attempt 1/4 outcome=no-stream", + ]); + assert.deepEqual(debug, []); +}); + +test("a non-OpenAI body ending without an OpenAI terminal logs nothing", async () => { + const { warn, debug, hooks } = capture(); + await run( + streamFrom(['event: content_block_delta\ndata: {"type":"content_block_delta"}\n\n']), + async () => null, + hooks + ); + assert.deepEqual(warn, []); + assert.deepEqual(debug, []); +}); + +test("every outcome formats with the attempt token and no undefined fields", () => { + const events: ContinuationOutcome[] = [ + { attempt: 2, outcome: "suffix", suffixChars: 7 }, + { attempt: 2, outcome: "overlap-reject", overlapChars: 3 }, + { attempt: 1, outcome: "terminal" }, + { attempt: 1, outcome: "empty" }, + { attempt: 1, outcome: "no-stream" }, + { attempt: 0, outcome: "refused", reason: "not-continuable" }, + ]; + for (const event of events) { + const line = formatContinuationOutcome(event); + assert.match(line, new RegExp(`^mid-stream continuation attempt ${event.attempt}/4 `)); + assert.doesNotMatch(line, /undefined|null/); + } +}); diff --git a/tests/unit/ui/free-badge-strict-flag.test.tsx b/tests/unit/ui/free-badge-strict-flag.test.tsx new file mode 100644 index 0000000000..41cc80bcd9 --- /dev/null +++ b/tests/unit/ui/free-badge-strict-flag.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +// +// #13645: the provider-page model lists compute the "Free" badge through +// isModelFreeBadge and read FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER from +// /api/settings/feature-flags. Flag off (and flag unreadable) must render exactly the +// historical badges; flag on removes only the provably wrong ones. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "test-provider" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/providers/test-provider", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +vi.mock("@/shared/components", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +type FlagMode = "on" | "off" | "error"; + +function mockFlagFetch(mode: FlagMode) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (!url.includes("/api/settings/feature-flags")) { + return new Response("{}", { status: 404 }); + } + if (mode === "error") return new Response("boom", { status: 500 }); + return new Response( + JSON.stringify({ + flags: [ + { + key: "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER", + effectiveValue: mode === "on" ? "true" : "false", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) + ); +} + +const commonProps = { + modelAliases: {}, + description: "", + inputLabel: "Model ID", + inputPlaceholder: "", + copied: undefined, + onCopy: vi.fn(), + onSetAlias: vi.fn().mockResolvedValue(undefined), + onDeleteAlias: vi.fn(), + t: (k: string) => k, + effectiveModelNormalize: () => false, + effectiveModelPreserveDeveloper: () => true, + getUpstreamHeadersRecord: () => ({}), + saveModelCompatFlags: vi.fn().mockResolvedValue(undefined), + isModelHidden: () => false, + onToggleHidden: vi.fn().mockResolvedValue(undefined), + onBulkToggleHidden: vi.fn().mockResolvedValue(undefined), +}; + +function freeBadgeCount(container: HTMLElement): number { + return Array.from(container.querySelectorAll('[data-testid="badge"]')).filter((el) => + /^(Free|freeBadge)$/.test((el.textContent || "").trim()) + ).length; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe( + "provider-page Free badge vs FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER", + { timeout: 120_000 }, + () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function renderPassthrough() { + const { default: PassthroughModelsSection } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection"); + await act(async () => { + root.render( + + ); + }); + await flush(); + } + + async function renderCompatible() { + const { default: CompatibleModelsSection } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection"); + await act(async () => { + root.render( + + ); + }); + await flush(); + } + + it("flag off: a paid registered provider keeps the historical badges (name + :free)", async () => { + mockFlagFetch("off"); + await renderPassthrough(); + expect(freeBadgeCount(container)).toBe(2); + }); + + it("flag unreadable: fails closed to the historical badges", async () => { + mockFlagFetch("error"); + await renderPassthrough(); + expect(freeBadgeCount(container)).toBe(2); + }); + + it("flag on: the name heuristic and :free on a paid registered provider lose the badge", async () => { + mockFlagFetch("on"); + await renderPassthrough(); + expect(freeBadgeCount(container)).toBe(0); + }); + + it("compatible node pointing at a :free model keeps its badge with the flag off", async () => { + mockFlagFetch("off"); + await renderCompatible(); + expect(freeBadgeCount(container)).toBe(1); + }); + + it("compatible node pointing at a :free model keeps its badge with the flag on", async () => { + mockFlagFetch("on"); + await renderCompatible(); + expect(freeBadgeCount(container)).toBe(1); + }); + } +); diff --git a/tests/unit/update-routes-keep-omitted-fields.test.ts b/tests/unit/update-routes-keep-omitted-fields.test.ts new file mode 100644 index 0000000000..e5297b2719 --- /dev/null +++ b/tests/unit/update-routes-keep-omitted-fields.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Three update routes merged the parsed body over the stored row, and zod 4 filled every +// omitted field with its creation default: a rename re-enabled a disabled reasoning rule, +// wiped a playground preset's params and reset a proxy's address family. A partial update +// must only change what the client sent. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-update-omitted-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const rulesDb = await import("../../src/lib/db/reasoningRoutingRules.ts"); +const ruleRoute = await import("../../src/app/api/settings/reasoning-routing-rules/[id]/route.ts"); +const presetsDb = await import("../../src/lib/db/playgroundPresets.ts"); +const presetRoute = await import("../../src/app/api/playground/presets/[id]/route.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const { handleProxyUpdate } = await import("../../src/lib/api/proxyRegistryRouteHandlers.ts"); +const bulkImportRoute = await import("../../src/app/api/settings/proxies/bulk-import/route.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function jsonRequest(method: string, body: unknown) { + return new Request("http://localhost/api/test", { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("renaming a disabled reasoning rule keeps it disabled and keeps its settings", async () => { + rulesDb.invalidateReasoningRoutingRuleCache(); + const rule = await rulesDb.createReasoningRoutingRule({ + name: "before", + description: "keep me", + scope: "global", + sourceEffort: "any", + requestTags: ["coding"], + tagMatchMode: "all", + effortMode: "inherit", + targetKind: "keep", + budgetAction: "preserve", + priority: 7, + enabled: false, + }); + + const response = await ruleRoute.PATCH(jsonRequest("PATCH", { name: "after" }), { + params: Promise.resolve({ id: rule.id }), + }); + + assert.equal(response.status, 200); + const stored = await rulesDb.getReasoningRoutingRuleById(rule.id); + assert.equal(stored?.name, "after"); + assert.equal(stored?.enabled, false); + assert.equal(stored?.priority, 7); + assert.equal(stored?.description, "keep me"); + assert.deepEqual(stored?.requestTags, ["coding"]); + assert.equal(stored?.tagMatchMode, "all"); +}); + +test("renaming a playground preset keeps its params", async () => { + const preset = presetsDb.createPlaygroundPreset({ + name: "before", + endpoint: "chat", + model: "m", + system: null, + params: { temperature: 0.2 }, + }); + + const response = await presetRoute.PUT(jsonRequest("PUT", { name: "after" }), { + params: Promise.resolve({ id: preset.id }), + }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { name: string; params: Record }; + assert.equal(body.name, "after"); + assert.deepEqual(body.params, { temperature: 0.2 }); +}); + +test("renaming a proxy keeps its address family", async () => { + const proxy = await proxiesDb.createProxy({ + name: "before", + type: "http", + host: "10.2.0.1", + port: 8080, + family: "ipv6", + }); + + const response = await handleProxyUpdate(jsonRequest("PATCH", { id: proxy.id, name: "after" })); + + assert.equal(response.status, 200); + const stored = await proxiesDb.getProxyById(proxy.id); + assert.equal(stored?.name, "after"); + assert.equal(stored?.family, "ipv6"); +}); + +test("re-importing a proxy without a family keeps its family", async () => { + const proxy = await proxiesDb.createProxy({ + name: "imported", + type: "http", + host: "10.2.0.2", + port: 8080, + family: "ipv4", + }); + + const response = await bulkImportRoute.POST( + jsonRequest("POST", { items: [{ name: "imported again", host: "10.2.0.2", port: 8080 }] }) + ); + + assert.equal(response.status, 200); + const stored = await proxiesDb.getProxyById(proxy.id); + assert.equal(stored?.name, "imported again"); + assert.equal(stored?.family, "ipv4"); +}); + +test("creating a proxy without a family still stores auto", async () => { + const proxy = await proxiesDb.createProxy({ + name: "fresh", + type: "http", + host: "10.2.0.3", + port: 8080, + }); + assert.equal((await proxiesDb.getProxyById(proxy.id))?.family, "auto"); +}); diff --git a/tests/unit/wal-maintenance.test.ts b/tests/unit/wal-maintenance.test.ts index 8447de2829..0ae01c1ce3 100644 --- a/tests/unit/wal-maintenance.test.ts +++ b/tests/unit/wal-maintenance.test.ts @@ -164,3 +164,170 @@ test("start is silent and stateless under the test-process gate", async () => { test.beforeEach(async () => { (await import("../../src/lib/db/walMaintenance.ts")).__resetForTests(); }); + +test("mergeBusyTotal keeps the max, floors at 0", async () => { + const { mergeBusyTotal } = await import("../../src/lib/db/walMaintenance.ts"); + assert.equal(mergeBusyTotal(5, 3), 5); + assert.equal(mergeBusyTotal(3, 5), 5); + assert.equal(mergeBusyTotal(0, 0), 0); + assert.equal(mergeBusyTotal(-2, -7), 0); + assert.equal(mergeBusyTotal(2.9, 1), 2); +}); + +test("loadPersistedBusyTotal reads the key, falls back to 0", async () => { + const { loadPersistedBusyTotal } = await import("../../src/lib/db/walMaintenance.ts"); + const store = new Map([["walMaintenance/busyTotal", "41"]]); + const db = { + pragma: () => [{ busy: 0, log: 0, checkpointed: 0 }], + prepare: (_sql: string) => ({ + get: () => { + const v = store.get("walMaintenance/busyTotal"); + return v === undefined ? undefined : { value: v }; + }, + run: () => {}, + }), + }; + assert.equal(loadPersistedBusyTotal(db as never), 41); + store.set("walMaintenance/busyTotal", "abc"); + assert.equal(loadPersistedBusyTotal(db as never), 0); + store.delete("walMaintenance/busyTotal"); + assert.equal(loadPersistedBusyTotal(db as never), 0); +}); + +test("flushBusyTotal is a no-op with nothing pending or no open handle", async () => { + const { flushBusyTotal } = await import("../../src/lib/db/walMaintenance.ts"); + let prepared = 0; + const db = { + open: true, + prepare: () => { + prepared++; + return { run: () => {} }; + }, + }; + assert.equal(flushBusyTotal(db as never), false); + assert.equal(flushBusyTotal(null), false); + assert.equal(prepared, 0); +}); + +/** + * The scheduler is gated off inside test runners (isAutomatedTestProcess), so the real boot + * wiring runs in a child Node process that is not a test process. It drives the actual + * startWalMaintenance()/stopWalMaintenance() against a real SQLite file; only the checkpoint + * pragma result is scripted (busy vs clean) so contention can be produced on demand. Module + * paths travel through env vars so no argv token makes the child look like a test runner. + */ +const CHILD_SCRIPT = ` +const { startWalMaintenance, stopWalMaintenance, getWalMaintenanceState } = await import(process.env.WAL_MODULE_URL); +const { tryOpenSync } = await import(process.env.DRIVER_MODULE_URL); +const file = process.env.WAL_DB_FILE; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const real = tryOpenSync(file); +if (!real) { console.log("WALCHILD " + JSON.stringify({ skipped: true })); process.exit(0); } +let mode = "busy"; +const writes = []; +const db = { + get open() { return real.open; }, + pragma: (s, o) => s.startsWith("wal_checkpoint") + ? [mode === "busy" ? { busy: 1, log: 5, checkpointed: 0 } : { busy: 0, log: 0, checkpointed: 0 }] + : real.pragma(s, o), + prepare: (sql) => { if (/INSERT/i.test(sql)) writes.push(mode); return real.prepare(sql); }, + exec: (sql) => real.exec(sql), + close: () => real.close(), +}; +const persisted = () => Number(real.prepare("SELECT value FROM key_value WHERE namespace='walMaintenance' AND key='busyTotal'").get()?.value ?? 0); +const env = { OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "60", OMNIROUTE_WAL_PASSIVE_INTERVAL_MS: "0" }; +const out = {}; +startWalMaintenance(db, file, env); +out.restoredAtBoot = getWalMaintenanceState().busyTotal; +await sleep(400); +out.afterBusy = { total: getWalMaintenanceState().busyTotal, persisted: persisted(), writes: writes.length }; +mode = "ok"; +await sleep(300); +out.afterOkTick = { total: getWalMaintenanceState().busyTotal, persisted: persisted(), busyWrites: writes.filter((m) => m === "busy").length }; +mode = "busy"; +await sleep(300); +out.beforeStop = { total: getWalMaintenanceState().busyTotal, persisted: persisted() }; +mode = "stopping"; +stopWalMaintenance(); +out.afterStop = { persisted: persisted(), busyWrites: writes.filter((m) => m === "busy").length, stopWrites: writes.filter((m) => m === "stopping").length }; +startWalMaintenance(db, file, env); +out.restoredAfterRestart = getWalMaintenanceState().busyTotal; +stopWalMaintenance(); +real.close(); +console.log("WALCHILD " + JSON.stringify(out)); +process.exit(0); +`; + +test("boot wiring: restores the persisted total, never writes on a busy tick, flushes on a clean tick and at stop", async (t) => { + const { spawnSync } = await import("node:child_process"); + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const { pathToFileURL } = await import("node:url"); + const { tryOpenSync } = await import("../../src/lib/db/adapters/driverFactory.ts"); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-wal-boot-")); + const file = path.join(dir, "storage.sqlite"); + const seed = tryOpenSync(file); + if (!seed) { + fs.rmSync(dir, { recursive: true, force: true }); + t.skip("no sync SQLite driver available"); + return; + } + try { + seed.exec( + "CREATE TABLE IF NOT EXISTS key_value (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key))" + ); + seed + .prepare( + "INSERT INTO key_value (namespace, key, value) VALUES ('walMaintenance', 'busyTotal', '41')" + ) + .run(); + seed.close(); + + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const env: NodeJS.ProcessEnv = { + ...process.env, + WAL_MODULE_URL: pathToFileURL(path.join(repoRoot, "src/lib/db/walMaintenance.ts")).href, + DRIVER_MODULE_URL: pathToFileURL(path.join(repoRoot, "src/lib/db/adapters/driverFactory.ts")) + .href, + WAL_DB_FILE: file, + NODE_ENV: "production", + }; + delete env.VITEST; + delete env.NODE_TEST_CONTEXT; + const child = spawnSync( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "-e", CHILD_SCRIPT], + { cwd: repoRoot, env, encoding: "utf8", timeout: 120_000 } + ); + const line = (child.stdout || "").split("\n").find((l) => l.startsWith("WALCHILD ")); + assert.ok(line, `child produced no result (status ${child.status}): ${child.stderr}`); + const out = JSON.parse(line.slice("WALCHILD ".length)); + if (out.skipped) { + t.skip("child could not open a sync SQLite driver"); + return; + } + + assert.equal(out.restoredAtBoot, 41, "boot restores the persisted counter"); + assert.ok(out.afterBusy.total > 41, "busy ticks are counted in memory"); + assert.equal(out.afterBusy.writes, 0, "no database write on the busy path"); + assert.equal(out.afterBusy.persisted, 41, "persisted value untouched while contended"); + + assert.equal(out.afterOkTick.busyWrites, 0); + assert.equal( + out.afterOkTick.persisted, + out.afterOkTick.total, + "a clean tick flushes the delta" + ); + + assert.ok(out.beforeStop.total > out.afterOkTick.total, "more busy ticks after the flush"); + assert.equal(out.beforeStop.persisted, out.afterOkTick.total, "still no write while busy"); + assert.equal(out.afterStop.busyWrites, 0); + assert.equal(out.afterStop.stopWrites, 1, "exactly one flush at stop"); + assert.equal(out.afterStop.persisted, out.beforeStop.total, "stop flushes the remaining delta"); + assert.equal(out.restoredAfterRestart, out.beforeStop.total, "restart restores the full total"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});