From 516927196c4f6b6205dc91387c89a159341d8d3c Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:20:19 +0200 Subject: [PATCH 01/36] fix(call-logs): zod write-point guard for error_type (#13441) A write-boundary guard for `error_type`: `toStoredErrorType()` validates what `saveCallLog` stores against the vocabulary (Zod enum built once), as defense in depth on top of #13281. Maintainer rework before merge (kept the idea, no default behavior change): - Dropped the redundant `SCHEMA_SQL` column (migration 158 already creates it) and the string-absence "migration 177" test; the real `PRAGMA table_info` test is back. - Restored #13281's changelog fragment, which this branch had deleted, and renamed this PR's own fragment to `13441-error-type-write-guard.md`. - The guard is now exercised for real: the exported function is tested with out-of-vocabulary values and an end-to-end drift test that changes a classifier family at runtime. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13441-error-type-write-guard.md | 1 + config/quality/file-size-baseline.json | 3 +- src/lib/db/core.ts | 5 +- src/lib/usage/callLogs.ts | 5 +- src/lib/usage/callLogs/format.ts | 30 ++++ tests/unit/call-log-error-type.test.ts | 163 +++++++++++++++++- 6 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/13441-error-type-write-guard.md 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/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index fef8ea6cfe..59f818b9f1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_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.", @@ -470,7 +471,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, 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/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 ec27a7e993..0f093474dd 100644 --- a/src/lib/usage/callLogs/format.ts +++ b/src/lib/usage/callLogs/format.ts @@ -1,7 +1,9 @@ +import { z } from "zod"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; import { classifyProviderError, type ErrorTypeContract, + ERROR_TYPE_CONTRACT, } from "@omniroute/open-sse/services/errorClassifier.ts"; import { sanitizeErrorMessage, @@ -190,3 +192,31 @@ export function classifyCallLogError( 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/tests/unit/call-log-error-type.test.ts b/tests/unit/call-log-error-type.test.ts index 112f492ff9..3f2696f65c 100644 --- a/tests/unit/call-log-error-type.test.ts +++ b/tests/unit/call-log-error-type.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getDbInstance, resetDbInstance } from "../../src/lib/db/core.ts"; -import { classifyCallLogError } from "../../src/lib/usage/callLogs/format.ts"; +import { classifyCallLogError, toStoredErrorType } from "../../src/lib/usage/callLogs/format.ts"; import { saveCallLog } from "../../src/lib/usage/callLogs.ts"; import { ERROR_TYPE_CUTOVER_ISO, getErrorTypeBreakdown } from "../../src/lib/db/callLogStats.ts"; import { getCallLogsForExport } from "../../src/lib/usage/callLogExportSource.ts"; @@ -323,3 +323,164 @@ test("log export keeps both legacy NULL and the new unknown error_type intact", 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]); + } +}); From 831b485e0894064bf0d805eb6be32b18328f3676 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 12:28:29 -0300 Subject: [PATCH 02/36] test(batches): rename the two seeded-batch labels that gitleaks reported as secrets (#13729) The labels wvxc-route-401/wvxc-route-500 sat right after a key*.id argument and cleared the gitleaks generic-api-key length and entropy floors; renamed to route401/route500 with a docblock stating the measured rule. Test-only; .gitleaks.toml untouched. Reviewed by 3 rounds of /omni-code-review (37 agents). --- .../13729-rename-wvxc-route-test-labels.md | 2 ++ .../batches-delete-completed-route-scope.test.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md 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/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" ); }); From cb420db64bdf0d6935967052e69d2ad913d5733a Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:48:06 +0200 Subject: [PATCH 03/36] fix(call-logs): hide search rows without a live provider (#13641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search stats and recent searches stop surfacing ghost rows: NULL and `-` providers are always hidden, and, behind the new `SEARCH_STATS_HIDE_DELETED_CONNECTIONS` flag (default off), traffic of a keyed provider whose connection was deleted is hidden too. Totals use the same guard as the per-provider rows, so they always agree. Maintainer rework before merge (kept the idea, no default behavior change): - Keyless providers from the search registry (`duckduckgo-free`, `searxng-search`, anonymous `context7`) and providers served through a credential fallback (`perplexity-search` on a `perplexity` key) stay visible in both modes — the original filter dropped them because they have no `provider_connections` row. - Tests use real registry ids and cover flag off (historical stats) and flag on, including the analytics route; #13281's changelog fragment restored. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13641-search-ghost.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- src/i18n/messages/am.json | 3 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/az.json | 1 + src/i18n/messages/bg.json | 1 + src/i18n/messages/bn.json | 1 + src/i18n/messages/cs.json | 1 + src/i18n/messages/da.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/el.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/et.json | 1 + src/i18n/messages/fa.json | 1 + src/i18n/messages/fi.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ga.json | 1 + src/i18n/messages/gu.json | 1 + src/i18n/messages/ha.json | 3 +- src/i18n/messages/he.json | 1 + src/i18n/messages/hi.json | 1 + src/i18n/messages/hr.json | 1 + src/i18n/messages/hu.json | 1 + src/i18n/messages/hy.json | 3 +- src/i18n/messages/id.json | 1 + src/i18n/messages/ig.json | 3 +- src/i18n/messages/it.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ka.json | 3 +- src/i18n/messages/km.json | 1 + src/i18n/messages/kn.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/lt.json | 1 + src/i18n/messages/lv.json | 1 + src/i18n/messages/ml.json | 1 + src/i18n/messages/mr.json | 1 + src/i18n/messages/ms.json | 1 + src/i18n/messages/mt.json | 1 + src/i18n/messages/my.json | 1 + src/i18n/messages/ne.json | 1 + src/i18n/messages/nl.json | 1 + src/i18n/messages/no.json | 1 + src/i18n/messages/or.json | 1 + src/i18n/messages/pa.json | 1 + src/i18n/messages/phi.json | 1 + src/i18n/messages/pl.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/ro.json | 1 + src/i18n/messages/ru.json | 1 + src/i18n/messages/si.json | 1 + src/i18n/messages/sk.json | 1 + src/i18n/messages/sl.json | 1 + src/i18n/messages/sr.json | 1 + src/i18n/messages/sv.json | 1 + src/i18n/messages/sw.json | 1 + src/i18n/messages/ta.json | 1 + src/i18n/messages/te.json | 1 + src/i18n/messages/th.json | 1 + src/i18n/messages/tr.json | 1 + src/i18n/messages/uk-UA.json | 1 + src/i18n/messages/ur.json | 1 + src/i18n/messages/uz.json | 3 +- src/i18n/messages/vi.json | 1 + src/i18n/messages/yo.json | 3 +- src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/db/callLogStats.ts | 114 +++++++-- .../constants/featureFlagDefinitions.ts | 12 + tests/unit/call-log-error-type.test.ts | 112 +++++++++ tests/unit/call-log-search-ghost.test.ts | 223 ++++++++++++++++++ tests/unit/db-call-log-stats-3500.test.ts | 23 +- tests/unit/feature-flags-settings.test.ts | 3 +- .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 75 files changed, 543 insertions(+), 34 deletions(-) create mode 100644 changelog.d/fixes/13641-search-ghost.md create mode 100644 tests/unit/call-log-search-ghost.test.ts 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/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 65a5bcac12..08b2d2ef68 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 +56 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 (24) | Key | Type | Default | Restart | Description | | ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -115,6 +115,7 @@ 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. | ### CLI (5) @@ -195,7 +196,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 55 flags + // ... all 56 flags ], "summary": { "total": 54, diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index 96c5068ff8..2036958610 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 183a9f54f2..698f0cbef8 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13622,6 +13622,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..a01e18e899 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13622,6 +13622,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..8d16e23058 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13622,6 +13622,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..3846032106 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13622,6 +13622,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..ce349a5a07 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13622,6 +13622,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..bc28af33d8 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13622,6 +13622,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..73538324de 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13622,6 +13622,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..14e5505adf 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -13633,6 +13633,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..fc0eb6caf7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13633,6 +13633,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..937d85cb60 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13622,6 +13622,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..d5554a0533 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -13633,6 +13633,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..5c25ce0a17 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13622,6 +13622,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..ff023e2a32 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13622,6 +13622,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..d74e4e8bcd 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13622,6 +13622,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..366d7fb042 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -13633,6 +13633,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..a16822c964 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13622,6 +13622,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..7b19032e0f 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 9db4315147..1f4d539733 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13622,6 +13622,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..c1b3bdb760 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13622,6 +13622,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..95aa15d841 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -13633,6 +13633,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..2e4bb4aebf 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13622,6 +13622,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..f7278379d6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5f9a9f7459..bb3542ac2a 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13622,6 +13622,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..2d8b4757e7 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index bc41744b5b..cdba4ed437 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13622,6 +13622,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..9fb0325fe0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13622,6 +13622,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..4540a6c4c9 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 458baf9e35..a2b77e10b3 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -13633,6 +13633,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..ba4b595241 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -13633,6 +13633,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..57eee8427e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13622,6 +13622,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..a08cdd839e 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -13633,6 +13633,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..8aff7d5196 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -13633,6 +13633,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..0037892057 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -13633,6 +13633,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..f4d1f776c2 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13622,6 +13622,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..1af383c664 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13622,6 +13622,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..764ad3bd3f 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -13633,6 +13633,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..50f1c13f67 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -13633,6 +13633,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..763cac4f35 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -13633,6 +13633,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..2dd9cc5e20 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13622,6 +13622,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..dd5312a270 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13622,6 +13622,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..1042b25792 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -13633,6 +13633,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..772a29a182 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -13633,6 +13633,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..29c0295d3f 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13622,6 +13622,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..085adfd2c0 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13622,6 +13622,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..0f41d2ddd8 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13634,6 +13634,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..8781200294 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13623,6 +13623,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..eeae608e0e 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13622,6 +13622,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..67fe01b628 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13622,6 +13622,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..4db3be8481 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -13633,6 +13633,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..9c0c918456 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13622,6 +13622,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..acf35d736d 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -13633,6 +13633,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..9e75fd2a6e 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -13633,6 +13633,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..c61c4f0bc1 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13622,6 +13622,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..2c6101857a 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13622,6 +13622,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..f251f9610e 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13622,6 +13622,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..77b43172f3 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13622,6 +13622,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..e1a6b6a504 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13622,6 +13622,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..d31036fec1 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13622,6 +13622,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..159ecfc2e1 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13622,6 +13622,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..806daead55 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13622,6 +13622,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..a1ad8c20be 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 93033f589d..e20d18a938 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13634,6 +13634,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..f10f486ba1 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -14143,5 +14143,6 @@ "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." } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index d5f97f5896..ae49f9ce65 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13622,6 +13622,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..cfb4e67a7b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13622,6 +13622,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/db/callLogStats.ts b/src/lib/db/callLogStats.ts index 04af1b3571..35f7263919 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -1,5 +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. @@ -157,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[]; @@ -177,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 ` ) @@ -200,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(); @@ -207,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 }; @@ -220,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[]; } diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 8271d21c5b..94e6156609 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -570,6 +570,18 @@ 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", + }, // ──────────────── CLI (5) ──────────────── { diff --git a/tests/unit/call-log-error-type.test.ts b/tests/unit/call-log-error-type.test.ts index 3f2696f65c..fc8f9df767 100644 --- a/tests/unit/call-log-error-type.test.ts +++ b/tests/unit/call-log-error-type.test.ts @@ -484,3 +484,115 @@ test("saveCallLog stores unknown when the classifier emits a family outside the 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/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/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 9f0184f41c..f1634f6035 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 = 56; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 2f6a383044..25351eb7a3 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, 56); }); }); From 1d17c239d1030ca3e2c881d6b1cb12fcf8957c95 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:52:41 +0200 Subject: [PATCH 04/36] fix(build): stop the client bundle from reaching server-only modules, and make the guard find them (#13436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the production build break from `node:fs` reaching client bundles (`oauth.ts → cursorAgentCliVersion.ts` through the codebuddy-cn registry) and widens the client-bundle guard so it finds any Node builtin, not just the one that broke. Maintainer rework before merge (kept the idea, no default behavior change): - The guard was 11× slower (3.8s → ~40s) because resolved edges were not cached; with resolved edges and per-file verdicts cached it runs in ~4.6s. - Bare builtins that Next's client build polyfills (`path`, `os`, `crypto`, `buffer`, … from Next's own `resolve.fallback` list) are allowed consistently; `node:` imports are always flagged; a drift test fails if Next stops polyfilling an allowlisted name. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13436-client-bundle-server-only-guard.md | 1 + open-sse/services/model.ts | 60 +------- open-sse/services/providerAlias.ts | 58 ++++++++ open-sse/utils/cursorAgentCliVersion.ts | 7 +- open-sse/utils/cursorAgentCliVersionPin.ts | 7 + src/lib/combos/controlCenter.ts | 2 +- src/lib/oauth/constants/oauth.ts | 4 +- ...client-bundle-no-server-only-10692.test.ts | 128 ++++++++++++++++-- 8 files changed, 194 insertions(+), 73 deletions(-) create mode 100644 changelog.d/fixes/13436-client-bundle-server-only-guard.md create mode 100644 open-sse/services/providerAlias.ts create mode 100644 open-sse/utils/cursorAgentCliVersionPin.ts 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/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/utils/cursorAgentCliVersion.ts b/open-sse/utils/cursorAgentCliVersion.ts index 91bedd2206..ba0dda576a 100644 --- a/open-sse/utils/cursorAgentCliVersion.ts +++ b/open-sse/utils/cursorAgentCliVersion.ts @@ -19,12 +19,9 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { CURSOR_AGENT_CLI_VERSION } from "./cursorAgentCliVersionPin.ts"; -/** - * Pinned Agent CLI build id used when no local install is found (typical - * headless OmniRoute). Bump when refreshing Cursor CLI impersonation. - */ -export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a"; +export { CURSOR_AGENT_CLI_VERSION }; const VERSION_ID_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/; const CACHE_TTL_MS = 60 * 60 * 1000; diff --git a/open-sse/utils/cursorAgentCliVersionPin.ts b/open-sse/utils/cursorAgentCliVersionPin.ts new file mode 100644 index 0000000000..eeea27f015 --- /dev/null +++ b/open-sse/utils/cursorAgentCliVersionPin.ts @@ -0,0 +1,7 @@ +// Import-free pin so client bundles can read the version without pulling node-only detection code. + +/** + * Pinned Agent CLI build id used when no local install is found (typical + * headless OmniRoute). Bump when refreshing Cursor CLI impersonation. + */ +export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a"; 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/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/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` + ); + } +}); From 215ac43a703140fe7a91e21b02d484eae3bc2474 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:59:27 +0200 Subject: [PATCH 05/36] fix(proxy): keep proxy credentials holding a literal percent (#13605) Proxy credentials containing a literal `%` no longer throw `URIError`: every `decodeURIComponent` on proxy user/password is guarded. Maintainer rework before merge (kept the idea, no default behavior change): - HTTP proxies still failed because undici's `ProxyAgent` decodes the credentials itself; the dispatcher now builds undici's `Basic` token with the safe decoder and passes it as `token`, so a literal `%` works there too. - The three remaining unguarded sites (`mappers.ts`, `proxySubscription/parse.ts`, `subscriptionService.ts`) are guarded; tests run the real `createProxyDispatcher` against a local HTTP CONNECT proxy and a local SOCKS5 server that record what they received. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13605-socks-userinfo-decode-guard.md | 1 + open-sse/utils/proxyDispatcher.ts | 31 +- open-sse/utils/proxyFallback.ts | 5 +- src/lib/db/proxies/mappers.ts | 5 +- src/lib/db/settings.ts | 5 +- src/lib/proxySubscription/parse.ts | 39 +-- .../proxySubscription/subscriptionService.ts | 223 ++++++++------ src/shared/utils/decodeUserinfo.ts | 15 + .../unit/socks-userinfo-decode-guard.test.ts | 281 ++++++++++++++++++ 9 files changed, 492 insertions(+), 113 deletions(-) create mode 100644 changelog.d/fixes/13605-socks-userinfo-decode-guard.md create mode 100644 src/shared/utils/decodeUserinfo.ts create mode 100644 tests/unit/socks-userinfo-decode-guard.test.ts 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/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/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/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 cf92db7718..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"; @@ -50,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 { @@ -205,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 @@ -255,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 }); } @@ -406,7 +412,15 @@ async function keepOwnedSyncedRow( 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; @@ -415,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); @@ -432,86 +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) { - // 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, - }, - { claimOwnership: false } - ); - await keepOwnedSyncedRow(upserted, keptIds); - } + // 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, + }, + { 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 ? decodeURIComponent(coreUrl.username) : undefined, - password: coreUrl.password ? decodeURIComponent(coreUrl.password) : undefined, - source: "subscription", - subscriptionId: id, - }, - { claimOwnership: false } - ); - await keepOwnedSyncedRow(upserted, keptIds); - } catch { - warning = subscriptionErrorCode("LOCAL_CORE_ENDPOINT_INVALID"); + // 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); @@ -710,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/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/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"); + }); +}); From 7cabac4985e8abcd7a34bad285698eebb924c46a Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:08:21 +0200 Subject: [PATCH 06/36] fix(quota): bound routing caches with shared boundedMap factory (#13280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven routing/quota caches (quality states, account buckets, quota-fetcher/saturation/header caches, learned rate limits) sit behind a shared bounded map with LRU/TTL eviction instead of growing without bound. The learned-limits cap of 200 that the tip declared was never enforced. Maintainer rework before merge (kept the idea, no default behavior change): - Eviction logging goes through the project logger, aggregated (first eviction, then one summary line per minute per map) instead of a `console.warn` per eviction. - `refetch-lazy` and `hard-expire` behaved identically and are collapsed into `ttl`; protected entries (saturated account buckets, evaluator quality scores) are never evicted; caps raised to 2048–4096 so normal deployments never evict, with tests showing 300 learned limits and 600 cached entries all kept. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13280-bounded-routing-caches.md | 1 + config/quality/eslint-suppressions.json | 2 +- open-sse/services/genericQuotaFetcher.ts | 69 ++-- open-sse/services/rateLimitManager.ts | 29 +- open-sse/services/routing/quality.ts | 18 +- src/lib/quota/accountBuckets.ts | 20 +- src/lib/quota/boundedMap.ts | 179 ++++++++++ src/lib/quota/saturationSignals.ts | 36 +- tests/unit/quota-bounded-map.test.ts | 316 ++++++++++++++++++ 9 files changed, 589 insertions(+), 81 deletions(-) create mode 100644 changelog.d/fixes/13280-bounded-routing-caches.md create mode 100644 src/lib/quota/boundedMap.ts create mode 100644 tests/unit/quota-bounded-map.test.ts 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/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 2b2dabce66..27ebed935a 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -585,7 +585,7 @@ }, "open-sse/services/rateLimitManager.ts": { "@typescript-eslint/no-unused-vars": { - "count": 2 + "count": 1 } }, "open-sse/services/routing/index.ts": { 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/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/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/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(); + } +}); From 62cd27720ab26a3cb585cabda0d2c13d597b92ab Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:21:52 +0200 Subject: [PATCH 07/36] fix(db): persist WAL busy counter across restarts (#13218) The WAL busy counter survives restarts: it is persisted in `key_value` and restored at boot, so the health output no longer resets to zero after every restart. Maintainer rework before merge (kept the idea, no default behavior change): - `recordBusy()` no longer writes synchronously on the contended path (with `busy_timeout = 2000` that could block the event loop for up to 2s); it accumulates in memory and `flushBusyTotal()` upserts the delta on a clean passive/TRUNCATE tick or best-effort at stop. - The boot wiring is tested for real: a child Node process drives `startWalMaintenance` against a real SQLite file (restore at boot, zero writes while busy, one flush at stop, restore after restart). Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13218-wal-busy-counter.md | 1 + src/lib/db/walMaintenance.ts | 67 ++++++++ tests/unit/wal-maintenance.test.ts | 167 ++++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 changelog.d/fixes/13218-wal-busy-counter.md 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/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/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 }); + } +}); From 104a34c5f2f347d923048e7abfb2f2a70b5df848 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 13:24:09 -0300 Subject: [PATCH 08/36] chore(deps): bump the adm-zip override to ^0.6.1 (#13737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot #214 (GHSA-vwc7-r8mq-g2x9 / CVE-2026-76845, moderate): adm-zip 0.5.9–0.6.0 follows a symlink that already exists inside the extraction root and writes through it, outside the root. The advisory still reports `first_patched_version: null`, but 0.6.1 (published after the advisory) is the fix — `util/utils.js` gains `assertPathSafe`, which walks every path component below the root with `lstat` and throws on a symlink; `extractAllTo` calls it before every write. Verified by diffing the two tarballs. Reach in this repo: adm-zip is pulled only by `onnxruntime-node` (an optionalDependency, itself pinned by override) and used only in its install script to unpack the vendor's own runtime binary. No request path touches it. The override already existed at ^0.6.0 (PR #7732, the previous adm-zip CVE); this just raises the floor. Lockfile moves 0.6.0 → 0.6.1, nothing else. --- changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md | 1 + package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md 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/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..5230e1710e 100644 --- a/package.json +++ b/package.json @@ -503,7 +503,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" From e498c349e3b82fde387d6ce2794dc448337b7ac3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 13:24:30 -0300 Subject: [PATCH 09/36] fix(security): add Groq, xAI and OpenAI-compatible key shapes to the credential catalog (#13744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-r4q7-7f24-m29p. `CREDENTIAL_PATTERNS` (open-sse/utils/credentialPatterns.ts) is the single catalog iterated in order by both the opt-in credential-masker guardrail and the public error sanitizer. It had no entry for Groq (`gsk_`) or xAI (`xai-`), and only knew the exact 48-char OpenAI `sk-` form. Measured on the release tip before this change: | Shape | public sanitizer | guardrail | |------------------------------|------------------|-----------| | Groq gsk_ + 52 | LEAK | LEAK | | xAI xai- + 80 | LEAK | LEAK | | DeepSeek sk- + 32 hex | redacted | LEAK | | sk- + 20/36/40/51 (not 48) | redacted | LEAK | The public path already caught every `sk-` shape through STRONG_CREDENTIAL_TOKEN, so the advisory's "both layers" framing only holds for gsk_/xai-; for the sk- family the exposure was the guardrail. Adds `groq` and `xai` after `anthropic_alt`, and a generic `openai_compatible` `sk-` fallback as the LAST entry. Ordering matters: both consumers replace as they iterate, so `openai_proj`, `openai` and `anthropic*` stamp their specific label first and the fallback only sees shapes nothing else claimed. The lookbehind mirrors STRONG_CREDENTIAL_TOKEN so `risk-…`-style words do not match. All three regexes are a fixed prefix plus one bounded character class — linear, no nested quantifiers. Tests are red-first: the new guardrail cases (bare / sentence / JSON-body contexts per shape, plus label-ordering and negative cases) and the catalog coverage array in error-sensitive-redaction both failed on the tip. Follow-ups deliberately left out of scope: `tskey-auth-` (Tailscale) was never in the catalog, and the guardrail does not decode `\uXXXX` escapes the way the public path does. --- ...hsa-r4q7-credential-catalog-groq-xai-sk.md | 1 + open-sse/utils/credentialPatterns.ts | 19 ++ .../unit/credential-masker-guardrail.test.ts | 172 ++++++++++++++++++ tests/unit/error-sensitive-redaction.test.ts | 4 + 4 files changed, 196 insertions(+) create mode 100644 changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md 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/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: /(? { 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/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) { From b97338a8031f79e25f3a3436adfbded68ee0cfe0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 13:24:50 -0300 Subject: [PATCH 10/36] fix(security): pin the public-only guard on client-supplied image URLs (#13748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-34rg-3pqj-35g9. `fetchRemoteImage()` defaults to `getProviderOutboundGuard()` — the OPERATOR outbound policy, local-first by design so self-hosted providers on loopback/LAN keep working. Since #11062 added the `block-metadata` middle tier, a default install resolves to that mode: the string check only rejects 169.254/16 and the IMDS hostnames, and the DNS validation step is skipped entirely (it only runs under `public-only`). Three sinks feed that default with CALLER input, so a request body could make the server fetch `http://127.0.0.1:…` or any RFC-1918 host and forward the bytes upstream: - imageGeneration.ts `resolveImageSource()` — `image_url`, `mask_url`, message parts - imageUpscale/shared.ts `resolveUpscaleImageSource()` — 14 body aliases, `provider_options.*`, message parts (Stability, Topaz) - visionBridgeHelpers.ts `fetchRemoteImageAsDataUri()` — chat `image_url` parts inlined into the vision self-call plus the NanoBanana result-URL download, which is upstream-supplied rather than OmniRoute-controlled. Same trust confusion as GHSA-3f8g / GHSA-j7j4 on the search base URL: operator config and caller input must not share a guard. Each site now passes `guard: "public-only"` (string check + DNS validation of every answer), matching the siblings that already did it right — embeddings, the audio bridge and the AI Horde result download. `pinDns` is set only on the vision bridge. The other three sites use `globalThis.fetch`, and connection pinning would swap that for a raw undici fetch — the same reason the AI Horde site leaves it off. On the vision bridge a `fetchImpl` is injected, so `pinDns` there validates every DNS answer but cannot pin the connection; commented in place. Blind SSRF rather than full read: the bytes go upstream or into the vision self-call, not back to the caller — but the status oracle and upstream exfiltration are real. Tests are red-first — per sink, `http://127.0.0.1:1/x.png` and `http://192.168.1.50/x.png` are rejected with the injected fetch never called, and a public host whose DNS resolves to a public IP still downloads. --- .../ghsa-34rg-image-url-ssrf-public-only.md | 1 + open-sse/handlers/imageGeneration.ts | 15 +- open-sse/handlers/imageUpscale/shared.ts | 29 +-- src/lib/guardrails/visionBridgeHelpers.ts | 8 + .../vision-bridge-claude-wire.test.ts | 87 ++++++++- ...isionBridgeHelpers.callVisionModel.test.ts | 43 +++++ tests/unit/image-generation-handler.test.ts | 102 +++++++++++ tests/unit/image-upscale.test.ts | 165 ++++++++++++++++-- tests/unit/nanobanana-image-handler.test.ts | 63 +++++++ 9 files changed, 480 insertions(+), 33 deletions(-) create mode 100644 changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md 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/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/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/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/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; + } + }); +} From e7f9fec251fdc5df984750f0719309be983381f6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 13:25:13 -0300 Subject: [PATCH 11/36] =?UTF-8?q?fix(api):=20enforce=20API-key=20ownership?= =?UTF-8?q?=20on=20files=20and=20batches=20=E2=80=94=20null-owner=20record?= =?UTF-8?q?s=20and=20anonymous=20listing=20(#13749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-2jm2-mpx8-6523 and GHSA-m3hp-hq9g-fpmv, one root cause. `getApiKeyRequestScope()` never rejects: with the default REQUIRE_API_KEY=false the client-api policy admits both a missing and an invalid bearer as anonymous, and the scope comes back `{ apiKeyId: null, isSessionAuth: false }`. The `/v1/files` and `/v1/batches` routes then treated "null" as permissive in two different ways: - GHSA-m3hp — the list routes coerced `apiKeyId || undefined`, and the DB layer reads `undefined` as "no owner filter", so an anonymous or invalid-bearer caller got every tenant's file and batch metadata, the same unfiltered view as the operator's dashboard. - GHSA-2jm2 — the single-record checks were `record.apiKeyId !== null && …`, so a record with no owner short-circuited to "allowed" for any caller: read, download raw content, delete, cancel, or use as a batch input. Null-owner records are common — every dashboard-session upload, and every batch output file inheriting a session batch's owner, which carries model responses. `api_key_id` has existed since the table was created (migration 028), so a null owner is not a legacy row; it is an unattributable write. No doc described it as shared — API_REFERENCE says files are scoped per key — and batch_api.test.ts pinned the by-id exposure as expected behaviour. One rule now, in `_helpers/apiKeyScope.ts`: - `canAccessOwnedRecord(scope, owner)`: a dashboard session is the instance operator and may act on any record; an API key acts on its own records only; a null owner is denied to every non-session caller. Applied to files GET / DELETE / content, batches GET / DELETE / cancel, and the batch-create input-file check. - `resolveListScope(scope)`: an explicit union for list/count reads — scoped to the presented key (a key wins even alongside a session cookie), instance-wide only for a session without a key, and 401 otherwise, including for a bearer that does not resolve to a key. There is no default that widens a read. This follows the GHSA-wvxc shape already used by the delete-completed sweep. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, because a null owner cannot be attributed. Subsumes #13683: it moved `scopeCheck` into the shared helper so a session can cancel any batch — kept, and its test ported — but it also kept null-owner records open on the premise they predate ownership tracking, which migration 028 contradicts. Tests are red-first. batch_api's by-id case is flipped to 404 with a negative assertion; batch-deletion-route-logic now imports the real helper instead of a local copy that had silently diverged from production; the two integration tests present a real key, since their subject is limits and rate logging, not auth. Co-authored-by: Markus Hartung --- ...hes-ownership-null-owner-anonymous-list.md | 1 + docs/reference/API_REFERENCE.md | 12 +- src/app/api/v1/_helpers/apiKeyScope.ts | 78 +++ src/app/api/v1/batches/[id]/cancel/route.ts | 9 +- src/app/api/v1/batches/[id]/route.ts | 18 +- .../api/v1/batches/delete-completed/route.ts | 4 +- src/app/api/v1/batches/route.ts | 25 +- src/app/api/v1/files/[id]/content/route.ts | 7 +- src/app/api/v1/files/[id]/route.ts | 20 +- src/app/api/v1/files/route.ts | 15 +- src/lib/db/batches.ts | 11 +- .../integration/batch-e2e-rate-limit.test.ts | 28 +- .../files-api-limit-validation.test.ts | 41 +- .../batch-cancel-session-auth-scope.test.ts | 133 +++++ tests/unit/batch-deletion-route-logic.test.ts | 29 +- tests/unit/batch_api.test.ts | 11 +- .../files-batches-ownership-2jm2-m3hp.test.ts | 490 ++++++++++++++++++ 17 files changed, 861 insertions(+), 71 deletions(-) create mode 100644 changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md create mode 100644 tests/unit/batch-cancel-session-auth-scope.test.ts create mode 100644 tests/unit/files-batches-ownership-2jm2-m3hp.test.ts 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/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/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/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/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/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/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"); + }); +}); From c8b24ffc30627884cd81c48a3714ee574c9266eb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 13:25:35 -0300 Subject: [PATCH 12/36] fix(authz): gate the cli-tools status and skills execution routes to LOCAL_ONLY (#13745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-35fw-cv32-2373 and GHSA-jx89-f37j-pq89 — the same defect class as /api/acp/agents (GHSA-hf57): a route whose handler chain spawns a host process was classified Tier 3 MANAGEMENT only, and requireManagementAuth() waives auth when requireLogin=false. Hard Rules #15/#17 require the LOCAL_ONLY gate, which runs on the stamped real peer before any auth check. cli-tools (GHSA-35fw): 14 routes reach getCliRuntimeStatus() -> locateCommand() -> runProcess("sh", ["-c", 'command -v -- "$1"']) -> spawn(), exactly like their six gated siblings (forge/grok-build/jcode/qwen/omp/letta-settings): all-statuses, status, and the claude/cline/codewhale/codex/crush/deepseek-tui/ droid/kilo/openclaw/pi/smelt-settings routes. The advisory counted 13; it missed /api/cli-tools/detect, which is heavier — detectAllTools() runs execFile(binary, ["--version"]) and execFile("which") per tool. skills (GHSA-jx89): POST /api/skills/install stores the request's handlerCode verbatim as the skill handler with no allowlist, so a value equal to a built-in name (execute_command / eval_code) aliases the real sandboxed built-in; POST /api/skills/executions then runs it. The sandbox is a real container, but the spawn is transitive, which is why the 6A.8 source scan never flagged it. Entries are exact paths, not a /api/cli-tools/ blanket prefix: apply, backups, config, guide-settings, hermes-agent-settings, keys, logs, openclaw/auto-order and codex-profiles do not spawn and remote dashboards use them. All 16 are mirrored into SPAWN_CAPABLE_PREFIXES (no manage-scope bypass) and added to the route-guard-membership roots so the gate enforces them from now on. Functional trade-off, same one already accepted for grok/forge/jcode/qwen: a dashboard served through a tunnel no longer shows the CLI Tools status badges. Tests are red-first. Two existing negative controls pointed at routes that turn out to spawn (/api/cli-tools/all-statuses, /api/skills/install); they now point at routes that genuinely do not (/api/cli-tools/config, /api/skills/marketplace, /api/skills/skillssh/install), so the non-over-gating assertions are kept. --- .../fixes/ghsa-35fw-jx89-local-only-gates.md | 1 + docs/openapi.yaml | 39 +++++++ docs/security/ROUTE_GUARD_TIERS.md | 73 +++++++------ scripts/check/check-route-guard-membership.ts | 20 ++++ src/server/authz/routeGuard.ts | 23 ++++ src/shared/constants/spawnCapablePrefixes.ts | 19 ++++ stryker.conf.json | 2 + .../authz/route-guard-skills-collect.test.ts | 10 +- ...spawn-capable-prefixes-client-safe.test.ts | 24 ++++- .../unit/check-route-guard-membership.test.ts | 65 +++++++++-- ...uard-cli-tools-settings-local-only.test.ts | 102 ++++++++++++++++++ ...rd-forge-jcode-settings-local-only.test.ts | 7 +- ...ard-grok-build-settings-local-only.test.ts | 7 +- ...te-guard-skills-execute-local-only.test.ts | 74 +++++++++++++ 14 files changed, 412 insertions(+), 54 deletions(-) create mode 100644 changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md create mode 100644 tests/unit/route-guard-cli-tools-settings-local-only.test.ts create mode 100644 tests/unit/route-guard-skills-execute-local-only.test.ts 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/docs/openapi.yaml b/docs/openapi.yaml index 16a39eef8d..ac93ffb20e 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 @@ -11709,6 +11745,7 @@ paths: tags: - Skills summary: "GET skills › executions" + x-loopback-only: true responses: "200": description: OK @@ -11716,6 +11753,7 @@ paths: tags: - Skills summary: "POST skills › executions" + x-loopback-only: true responses: "200": description: OK @@ -11724,6 +11762,7 @@ paths: tags: - Skills summary: "POST skills › install" + x-loopback-only: true responses: "200": description: OK diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index fe1ca48ba2..d3b69ff2d7 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/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/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index b987f4fe49..a2b6a3571b 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/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/stryker.conf.json b/stryker.conf.json index 6feacd68f8..1e95da7312 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -373,6 +373,7 @@ "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 +383,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", 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/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/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); +}); From 45c54ca8aa47668b180524865899d05350e0af65 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:57:39 +0200 Subject: [PATCH 13/36] fix(dashboard): require provider free-tier for the Free badge (#13645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-page Free badge can require a free tier the provider actually honors: behind the new `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` flag (default off) the display-name "free" heuristic, non-boolean `free` fields and `:free` suffixes on registered providers without a documented free tier no longer light the badge. With the flag off the historical rule is unchanged. Maintainer rework before merge (kept the idea, no default behavior change): - `:free` models on free-tier providers and on compatible nodes (OpenRouter-style endpoints) keep the badge in both modes — the original change dropped them. - Test D derives its provider set from `FREE_MODEL_BUDGETS` instead of a hard-coded allowlist; a vitest render covers both sections with the flag endpoint on, off and erroring. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13645-free-badge-auth.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- .../components/CompatibleModelsSection.tsx | 35 ++- .../components/PassthroughModelsSection.tsx | 35 ++- .../[id]/components/useStrictFreeBadge.ts | 38 ++++ src/i18n/messages/am.json | 3 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/az.json | 1 + src/i18n/messages/bg.json | 1 + src/i18n/messages/bn.json | 1 + src/i18n/messages/cs.json | 1 + src/i18n/messages/da.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/el.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/et.json | 1 + src/i18n/messages/fa.json | 1 + src/i18n/messages/fi.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ga.json | 1 + src/i18n/messages/gu.json | 1 + src/i18n/messages/ha.json | 3 +- src/i18n/messages/he.json | 1 + src/i18n/messages/hi.json | 1 + src/i18n/messages/hr.json | 1 + src/i18n/messages/hu.json | 1 + src/i18n/messages/hy.json | 3 +- src/i18n/messages/id.json | 1 + src/i18n/messages/ig.json | 3 +- src/i18n/messages/it.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ka.json | 3 +- src/i18n/messages/km.json | 1 + src/i18n/messages/kn.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/lt.json | 1 + src/i18n/messages/lv.json | 1 + src/i18n/messages/ml.json | 1 + src/i18n/messages/mr.json | 1 + src/i18n/messages/ms.json | 1 + src/i18n/messages/mt.json | 1 + src/i18n/messages/my.json | 1 + src/i18n/messages/ne.json | 1 + src/i18n/messages/nl.json | 1 + src/i18n/messages/no.json | 1 + src/i18n/messages/or.json | 1 + src/i18n/messages/pa.json | 1 + src/i18n/messages/phi.json | 1 + src/i18n/messages/pl.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/ro.json | 1 + src/i18n/messages/ru.json | 1 + src/i18n/messages/si.json | 1 + src/i18n/messages/sk.json | 1 + src/i18n/messages/sl.json | 1 + src/i18n/messages/sr.json | 1 + src/i18n/messages/sv.json | 1 + src/i18n/messages/sw.json | 1 + src/i18n/messages/ta.json | 1 + src/i18n/messages/te.json | 1 + src/i18n/messages/th.json | 1 + src/i18n/messages/tr.json | 1 + src/i18n/messages/uk-UA.json | 1 + src/i18n/messages/ur.json | 1 + src/i18n/messages/uz.json | 3 +- src/i18n/messages/vi.json | 1 + src/i18n/messages/yo.json | 3 +- src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/freeModels.ts | 49 +++- tests/unit/feature-flags-settings.test.ts | 2 +- tests/unit/free-badge-provider-gate.test.ts | 211 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- tests/unit/ui/free-badge-strict-flag.test.tsx | 192 ++++++++++++++++ 77 files changed, 629 insertions(+), 35 deletions(-) create mode 100644 changelog.d/fixes/13645-free-badge-auth.md create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts create mode 100644 tests/unit/free-badge-provider-gate.test.ts create mode 100644 tests/unit/ui/free-badge-strict-flag.test.tsx 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/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 08b2d2ef68..92d3a5e33e 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 -56 flags across 6 categories. **Default** is the definition default — the value +57 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 (24) +### Runtime (25) | Key | Type | Default | Restart | Description | | ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -116,6 +116,7 @@ used when neither a DB override nor an environment variable is present. | `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. | ### CLI (5) @@ -196,7 +197,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 56 flags + // ... all 57 flags ], "summary": { "total": 54, 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/i18n/messages/am.json b/src/i18n/messages/am.json index 2036958610..b5e54246e5 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -14144,5 +14144,6 @@ "dismissAriaLabel": "ዝጋ" }, "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." + "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 698f0cbef8..3c11dfeeaf 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": "لوحة القيادة", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index a01e18e899..b69d3ef830 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", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 8d16e23058..d339622c4e 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": "Табло", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 3846032106..f71c640f2e 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", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index ce349a5a07..779b9aa8d6 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", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index bc28af33d8..bb762eb0b1 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", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 73538324de..f450065dbe 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", diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 14e5505adf..36a452db29 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": "Πίνακας Ελέγχου", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index fc0eb6caf7..ab60fdc72c 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", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 937d85cb60..975204f596 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", diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index d5554a0533..57f8ff8a2a 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", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 5c25ce0a17..9036d126e9 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", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index ff023e2a32..82bceacec6 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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index d74e4e8bcd..c9c7198587 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", diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 366d7fb042..9fae8d954f 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", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index a16822c964..a4cf886879 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", diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 7b19032e0f..e0f27afcc9 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -14144,5 +14144,6 @@ "dismissAriaLabel": "Yi watsi" }, "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." + "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 1f4d539733..36f6710fb1 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": "לוח מחוונים", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index c1b3bdb760..c4e4b45882 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": "डैशबोर्ड", diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 95aa15d841..f644e870f0 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", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 2e4bb4aebf..5262ba7957 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", diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index f7278379d6..93ee0438a7 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -14144,5 +14144,6 @@ "dismissAriaLabel": "Փակել" }, "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." + "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 bb3542ac2a..84c84f92ca 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", diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 2d8b4757e7..25aee1e25c 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -14144,5 +14144,6 @@ "dismissAriaLabel": "Wepụ" }, "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." + "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 cdba4ed437..d496da9676 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", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9fb0325fe0..41fcace798 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": "ダッシュボード", diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index 4540a6c4c9..cd24adee37 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -14144,5 +14144,6 @@ "dismissAriaLabel": "დახურვა" }, "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." + "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 a2b77e10b3..233acb4378 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": "ផ្ទាំងគ្រប់គ្រង", diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index ba4b595241..8309468c2f 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": "ಡ್ಯಾಶ್ಬೋರ್ಡ್", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 57eee8427e..15bb2b4bd0 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": "대시보드", diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index a08cdd839e..8ed56f5bd1 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ė", diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 8aff7d5196..4899ca8daf 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", diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 0037892057..371d9bc2be 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": "ഡാഷ്ബോർഡ്", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index f4d1f776c2..2923fa20aa 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", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 1af383c664..b081919333 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", diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 764ad3bd3f..a300d007e0 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", diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 50f1c13f67..7ea35b2b97 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": "ဒက်ရှ်ဘုတ်", diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 763cac4f35..bd557073d7 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": "ड्यासबोर्ड", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 2dd9cc5e20..5083f6fcb7 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", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index dd5312a270..5b408fad1e 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", diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index 1042b25792..1cb6aba10a 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": "ଡ୍ୟାସ୍ବୋର୍ଡ", diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 772a29a182..387b1ac39d 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": "ਡੈਸ਼ਬੋਰਡ", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 29c0295d3f..764e070689 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", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 085adfd2c0..77e38a1890 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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0f41d2ddd8..16cf1af947 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", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8781200294..3016f57616 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", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index eeae608e0e..b706cd004d 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", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 67fe01b628..736f561429 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": "Панель управления", diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 4db3be8481..9652eea964 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": "උපකරණ පුවරුව", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 9c0c918456..31fc20edf0 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", diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index acf35d736d..e0165dd767 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", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 9e75fd2a6e..0306ed17bc 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": "Контролна табла", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index c61c4f0bc1..b5ef90856b 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", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 2c6101857a..e6302e980c 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", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index f251f9610e..e88483c1ab 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", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 77b43172f3..cdf04594e4 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", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index e1a6b6a504..bd22f12e29 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": "แดชบอร์ด", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index d31036fec1..fe37549ab6 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", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 159ecfc2e1..828175ae30 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": "Приладова панель", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 806daead55..28edb542e0 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", diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index a1ad8c20be..13002cc69b 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -14144,5 +14144,6 @@ "dismissAriaLabel": "Yopish" }, "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." + "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 e20d18a938..f768f3e5d2 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", diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index f10f486ba1..a4d2d42bb1 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -14144,5 +14144,6 @@ "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ọ́.", - "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." + "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 ae49f9ce65..36fb5d0848 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": "仪表板", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index cfb4e67a7b..176eb328ff 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": "儀表板", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 94e6156609..b3bb087ec5 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -582,6 +582,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ 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", + }, // ──────────────── CLI (5) ──────────────── { 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/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index f1634f6035..ec5214a570 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 56; +const EXPECTED_FEATURE_FLAG_COUNT = 57; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry 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/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 25351eb7a3..12fd868206 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, 56); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 57); }); }); 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); + }); + } +); From b283ed1830cc02e9b728a8dedd1273fcd1e1c321 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:10:45 +0200 Subject: [PATCH 14/36] fix(api): use shared SOCKS5 flag reader in settings routes (#13646) Both settings routes use the shared `isSocks5ProxyEnabled()` reader instead of a copied check (identical logic, no behavior change). Maintainer rework before merge (kept the idea, no default behavior change): - The source-grep tests were replaced by behavioral tests of both routes across the flag on/off matrix (`GET /api/settings/proxies` reports `socks5Enabled`; `PUT /api/settings/proxy` accepts socks5 or returns 400). Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13646-socks-flag-reader.md | 1 + src/app/api/settings/proxies/route.ts | 7 +- src/app/api/settings/proxy/route.ts | 17 ++-- tests/unit/settings-socks-flag-reader.test.ts | 98 +++++++++++++++++++ 4 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fixes/13646-socks-flag-reader.md create mode 100644 tests/unit/settings-socks-flag-reader.test.ts 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/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/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/); + } + }); + } +}); From 520428c8ad1bbbd66231086629b390bbd8ac71e0 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:02:55 +0200 Subject: [PATCH 15/36] fix(resilience): honor user daily-reset clock for non-TPD quota cooldowns (#13440) Daily-quota lockouts on the non-TPD path honor the provider's configured daily-reset clock (`dailyQuotaResetTimezone`/hour) in combo routing instead of the host's midnight. Maintainer rework before merge (kept the idea, no default behavior change): - The process-lifetime clock cache is gone: the clock is resolved on each failure through the already-TTL'd `getCachedProviderNodes`, so a timezone change takes effect without a restart and a DB error is never cached as `{}` forever. - Round-robin combos are threaded too (the PR left them out); an option on `recordModelLockoutFailure` that could never run was removed; tests prove both call sites pass the configured clock. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13440-daily-reset-tz.md | 1 + config/quality/eslint-suppressions.json | 2 +- config/quality/file-size-baseline.json | 7 +- open-sse/services/accountFallback.ts | 44 +++- open-sse/services/combo.ts | 33 +-- .../services/combo/comboDailyResetClock.ts | 34 +++ .../services/combo/executeTargetAttempt.ts | 5 +- open-sse/services/combo/roundRobinCombo.ts | 5 +- open-sse/services/dailyQuotaReset.ts | 26 +- tests/unit/daily-reset-tz-threading.test.ts | 248 ++++++++++++++++++ 10 files changed, 359 insertions(+), 46 deletions(-) create mode 100644 changelog.d/fixes/13440-daily-reset-tz.md create mode 100644 open-sse/services/combo/comboDailyResetClock.ts create mode 100644 tests/unit/daily-reset-tz-threading.test.ts 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/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 27ebed935a..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": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 59f818b9f1..42f5bbe028 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -430,6 +430,7 @@ "_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_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, @@ -443,10 +444,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": 1215, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, "open-sse/utils/proxyFetch.ts": 1271, @@ -483,7 +484,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": 1208 }, "_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/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..89ab50a142 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,14 @@ 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"; export { RESET_WINDOW_NAMES, QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; export { scoreAutoTargets, expandAutoComboCandidatePool }; @@ -1081,4 +1061,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/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index bd948cf535..7c99d9902b 100644 --- a/open-sse/services/combo/executeTargetAttempt.ts +++ b/open-sse/services/combo/executeTargetAttempt.ts @@ -91,6 +91,7 @@ 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"; export async function executeTargetAttempt(opts: { index: number; @@ -835,7 +836,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/roundRobinCombo.ts b/open-sse/services/combo/roundRobinCombo.ts index 2cc3c6895f..1e79a94663 100644 --- a/open-sse/services/combo/roundRobinCombo.ts +++ b/open-sse/services/combo/roundRobinCombo.ts @@ -112,6 +112,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: { @@ -921,7 +922,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/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts index a5108d132f..46db112236 100644 --- a/open-sse/services/dailyQuotaReset.ts +++ b/open-sse/services/dailyQuotaReset.ts @@ -57,7 +57,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; @@ -75,7 +79,7 @@ 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; @@ -130,7 +134,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 +147,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/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))}` + ); +}); From cad0fcc65d7733a3ab292bac7ced5d40c0a850cc Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:07:42 +0200 Subject: [PATCH 16/36] fix(sse): bound daily quota cooldowns around daylight-saving transitions (#13671) Fixes the DST-gap bug in `nextDailyResetAtMs`: a reset hour that does not exist on the transition day landed one hour early (New York 02:00 came out as 01:00; Havana/Santiago midnight as 23:00 the day before). The walk across the gap is bounded to one day and uses a cached formatter. Maintainer rework before merge (kept the idea, no default behavior change): - Dropped the 24h clamp in `getMsUntilTomorrow` (on a 25h fall-back day 24.5h is the correct wait; clamping expired the lock 30 minutes early) and the unreachable `ms <= 0` branch, with their tests; characterization tests pin ordinary and fall-back days. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- changelog.d/fixes/13671-dst-gap.md | 1 + open-sse/services/dailyQuotaReset.ts | 97 +++++++++++++++++++++----- stryker.conf.json | 1 + tests/unit/daily-reset-dst-gap.test.ts | 79 +++++++++++++++++++++ 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/13671-dst-gap.md create mode 100644 tests/unit/daily-reset-dst-gap.test.ts 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/open-sse/services/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts index 46db112236..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; @@ -71,6 +84,35 @@ function addCalendarDay( 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, @@ -81,16 +123,33 @@ function zonedLocalToUtc( second: number, 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; } /** diff --git a/stryker.conf.json b/stryker.conf.json index 1e95da7312..0be11313fa 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -247,6 +247,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", 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" + ); +}); From ac52d4d9ea95011ca116eb699f245983f0bc88ab Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:25:43 +0200 Subject: [PATCH 17/36] fix(sse): omit synthetic Retry-After and mark retry provenance on drain path (#13672) Behind the new `RETRY_AFTER_PROVENANCE_ENABLED` flag (default off): `unavailableResponse` omits the synthetic `Retry-After: 1` when there is no real retry signal, marks `retry_after_provenance` on its bodies, and both combo drain readers parse prose retry hints from plain-text bodies too. With the flag off, headers and bodies are exactly as before. Maintainer rework before merge (kept the idea, no default behavior change): - A past `Retry-After` date is no longer labelled as an upstream signal with `Retry-After: 1`; non-JSON bodies (HTML 502 pages) log at debug instead of warning on every request. - The provenance claim is narrowed to responses built by `unavailableResponse`, documented in the flag row. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13672-retry-after-provenance.md | 1 + config/quality/file-size-baseline.json | 6 +- docs/reference/FEATURE_FLAGS.md | 7 +- .../services/combo/executeTargetAttempt.ts | 14 +- open-sse/services/combo/roundRobinCombo.ts | 9 +- open-sse/utils/error.ts | 93 +++++- src/i18n/messages/am.json | 4 + src/i18n/messages/ar.json | 4 + src/i18n/messages/az.json | 4 + src/i18n/messages/bg.json | 4 + src/i18n/messages/bn.json | 4 + src/i18n/messages/cs.json | 4 + src/i18n/messages/da.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/el.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/et.json | 4 + src/i18n/messages/fa.json | 4 + src/i18n/messages/fi.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ga.json | 4 + src/i18n/messages/gu.json | 4 + src/i18n/messages/ha.json | 4 + src/i18n/messages/he.json | 4 + src/i18n/messages/hi.json | 4 + src/i18n/messages/hr.json | 4 + src/i18n/messages/hu.json | 4 + src/i18n/messages/hy.json | 4 + src/i18n/messages/id.json | 4 + src/i18n/messages/ig.json | 4 + src/i18n/messages/it.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ka.json | 4 + src/i18n/messages/km.json | 4 + src/i18n/messages/kn.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/lt.json | 4 + src/i18n/messages/lv.json | 4 + src/i18n/messages/ml.json | 4 + src/i18n/messages/mr.json | 4 + src/i18n/messages/ms.json | 4 + src/i18n/messages/mt.json | 4 + src/i18n/messages/my.json | 4 + src/i18n/messages/ne.json | 4 + src/i18n/messages/nl.json | 4 + src/i18n/messages/no.json | 4 + src/i18n/messages/or.json | 4 + src/i18n/messages/pa.json | 4 + src/i18n/messages/phi.json | 4 + src/i18n/messages/pl.json | 4 + src/i18n/messages/pt-BR.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/ro.json | 4 + src/i18n/messages/ru.json | 4 + src/i18n/messages/si.json | 4 + src/i18n/messages/sk.json | 4 + src/i18n/messages/sl.json | 4 + src/i18n/messages/sr.json | 4 + src/i18n/messages/sv.json | 4 + src/i18n/messages/sw.json | 4 + src/i18n/messages/ta.json | 4 + src/i18n/messages/te.json | 4 + src/i18n/messages/th.json | 4 + src/i18n/messages/tr.json | 4 + src/i18n/messages/uk-UA.json | 4 + src/i18n/messages/ur.json | 4 + src/i18n/messages/uz.json | 4 + src/i18n/messages/vi.json | 4 + src/i18n/messages/yo.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + .../constants/featureFlagDefinitions.ts | 12 + stryker.conf.json | 1 + tests/unit/feature-flags-settings.test.ts | 2 +- tests/unit/retry-after-provenance.test.ts | 309 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 77 files changed, 705 insertions(+), 15 deletions(-) create mode 100644 changelog.d/fixes/13672-retry-after-provenance.md create mode 100644 tests/unit/retry-after-provenance.test.ts 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/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 42f5bbe028..1d8d34b04e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_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.", @@ -431,6 +432,7 @@ "_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_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, @@ -447,7 +449,7 @@ "open-sse/services/accountFallback.ts": 2493, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4080, - "open-sse/services/combo/executeTargetAttempt.ts": 1215, + "open-sse/services/combo/executeTargetAttempt.ts": 1223, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, "open-sse/utils/proxyFetch.ts": 1271, @@ -484,7 +486,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": 1208 + "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/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 92d3a5e33e..8e56aaaa51 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 -57 flags across 6 categories. **Default** is the definition default — the value +58 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 (25) +### Runtime (26) | Key | Type | Default | Restart | Description | | ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -117,6 +117,7 @@ used when neither a DB override nor an environment variable is present. | `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. | ### CLI (5) @@ -197,7 +198,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 57 flags + // ... all 58 flags ], "summary": { "total": 54, diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index 7c99d9902b..1d47679e32 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"; @@ -668,10 +673,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); @@ -703,11 +710,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 ( diff --git a/open-sse/services/combo/roundRobinCombo.ts b/open-sse/services/combo/roundRobinCombo.ts index 1e79a94663..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"; @@ -812,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); @@ -828,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( diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index fb72dcdb46..b8b8785c89 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -9,6 +9,7 @@ import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; export { redactSensitiveErrorText, sanitizeErrorMessage, sanitizeUpstreamDetails }; @@ -674,6 +675,41 @@ function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): return 1; } +/** + * #13672 — opt-in RETRY_AFTER_PROVENANCE_ENABLED (default off). Fails closed to + * the legacy Retry-After contract when the flag store cannot be read. + */ +export function isRetryAfterProvenanceEnabled(): boolean { + try { + return isFeatureFlagEnabled("RETRY_AFTER_PROVENANCE_ENABLED"); + } catch { + return false; + } +} + +/** + * Seconds until a concrete FUTURE retry time, or null when there is none: absent, + * non-positive or invalid values, numeric strings, and dates that already elapsed. + * Unlike normalizeRetryAfterSeconds it never invents a 1s wait; when it returns a + * number, that number equals normalizeRetryAfterSeconds for the same input. + */ +export function resolveRetryAfterHintSeconds( + retryAfter?: string | number | Date | null +): number | null { + if (typeof retryAfter === "number") { + if (!Number.isFinite(retryAfter) || retryAfter <= 0) return null; + if (retryAfter < 1_000_000_000) return Math.max(Math.ceil(retryAfter), 1); + } else if (typeof retryAfter === "string") { + if (retryAfter.trim() === "" || !Number.isNaN(Number(retryAfter))) return null; + } else if (!(retryAfter instanceof Date)) { + return null; + } + const now = Date.now(); + const retryTimeMs = new Date(retryAfter).getTime(); + if (!Number.isFinite(retryTimeMs) || retryTimeMs <= now) return null; + return Math.max(Math.ceil((retryTimeMs - now) / 1000), 1); +} + const MAX_PUBLIC_CONTEXT_LABEL_LENGTH = 256; function projectPublicContextLabel(value: unknown): string | null { @@ -733,6 +769,49 @@ export function parseAntigravityRetryTime(message: unknown): number | null { return totalMs > 0 ? totalMs : null; } +const MAX_PROSE_RETRY_MS = 24 * 60 * 60 * 1000; + +/** + * Retry delay in ms from upstream error prose (Antigravity "reset after 2h7m23s", + * generic "retry after 30s"), capped at 24h; null when the text carries no hint. + */ +export function parseProseRetryDelayMs(text: unknown): number | null { + if (typeof text !== "string" || text === "") return null; + const antigravityMs = parseAntigravityRetryTime(text); + if (antigravityMs) return Math.min(antigravityMs, MAX_PROSE_RETRY_MS); + const m = /retry\s+after\s+(\d{1,9})\s*s/i.exec(text); + const ms = m ? Number.parseInt(m[1], 10) * 1000 : 0; + return ms > 0 ? Math.min(ms, MAX_PROSE_RETRY_MS) : null; +} + +/** + * Combo drain paths: ISO retry time read from the prose of an upstream error body, + * JSON or plain text. Null when RETRY_AFTER_PROVENANCE_ENABLED is off (legacy: + * only structured retry fields are read) or when the text carries no hint. + */ +export function readProseRetryAfter(text: unknown): string | null { + if (!isRetryAfterProvenanceEnabled()) return null; + const ms = parseProseRetryDelayMs(text); + return ms ? new Date(Date.now() + ms).toISOString() : null; +} + +/** + * Combo drain paths: the upstream error body could not be read for a retry hint. + * A non-JSON body (an HTML 502 page, plain text) is ordinary, so it logs at debug; + * a failed clone means the body was already consumed and logs at warn. + */ +export function logRetryHintUnreadable( + log: { warn: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void }, + tag: string, + model: string, + status: number | undefined, + reason: "unparseable body" | "clone failed" +): void { + const message = `Retry hint unreadable for ${model} (${reason})`; + if (reason === "clone failed") log.warn(tag, message, { status }); + else log.debug?.(tag, message, { status }); +} + /** * Parse upstream provider error response * @param {Response} response - Fetch response from provider @@ -925,15 +1004,23 @@ export function unavailableResponse( retryAfter?: string | number | Date | null, retryAfterHuman?: string ) { - const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); + // #13672 (opt-in): only a concrete future retry time earns a Retry-After header, and the + // body says whether one existed. Off: legacy header, always present and clamped to >= 1s. + const provenance = isRetryAfterProvenanceEnabled(); + const retryAfterSec = provenance + ? resolveRetryAfterHintSeconds(retryAfter) + : normalizeRetryAfterSeconds(retryAfter); const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode); const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : ""; const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage; - return new Response(JSON.stringify({ error: { message: msg } }), { + const error = provenance + ? { message: msg, retry_after_provenance: retryAfterSec === null ? "none" : "signal" } + : { message: msg }; + return new Response(JSON.stringify({ error }), { status: statusCode, headers: { "Content-Type": "application/json", - "Retry-After": String(retryAfterSec), + ...(retryAfterSec === null ? {} : { "Retry-After": String(retryAfterSec) }), }, }); } diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index b5e54246e5..3cae5d670d 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3c11dfeeaf..e2ff82e523 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index b69d3ef830..8fc4d00a13 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index d339622c4e..adebbc4086 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index f71c640f2e..28be75ea9a 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 779b9aa8d6..f492b6c79a 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index bb762eb0b1..19c3f0efdf 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index f450065dbe..5fa30efbc3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 36a452db29..b2593fb696 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ab60fdc72c..26688cbc2c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 975204f596..d78a9c3df3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 57f8ff8a2a..8e3ef1eaa2 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 9036d126e9..bab8ea3f0b 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 82bceacec6..ca0fe6a732 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c9c7198587..d07fa8abd7 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 9fae8d954f..7aba7e6fab 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index a4cf886879..3bcafcaa71 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index e0f27afcc9..1439faeb11 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 36f6710fb1..93f525cfbf 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index c4e4b45882..4ea5afb924 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index f644e870f0..3bf2623806 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 5262ba7957..6edbe88ad7 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 93ee0438a7..18d196f830 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 84c84f92ca..9cb2d993dd 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 25aee1e25c..f3a76a6a99 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index d496da9676..736752b0b4 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 41fcace798..c1d729fb89 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index cd24adee37..6ce7ef156c 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 233acb4378..ccdb84e1f8 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index 8309468c2f..b64ec807fa 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 15bb2b4bd0..abdf698b5d 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 8ed56f5bd1..05f138d0fc 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 4899ca8daf..3ecffe6e0d 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 371d9bc2be..19cb3d36b5 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 2923fa20aa..4ee8d83b35 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index b081919333..bdfae76329 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index a300d007e0..4143bedd97 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 7ea35b2b97..fd3dee3f4f 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index bd557073d7..fddd2e0c87 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 5083f6fcb7..c66e68c213 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 5b408fad1e..427c6b2ec2 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index 1cb6aba10a..4b91bef1ad 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 387b1ac39d..b033cedd00 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 764e070689..309e91e251 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 77e38a1890..7faa0bb350 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 16cf1af947..d7fc969916 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13022,6 +13022,10 @@ "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." } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3016f57616..379f56bd87 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13011,6 +13011,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index b706cd004d..aaced7b98e 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 736f561429..010e4ea2a8 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 9652eea964..b076befc47 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 31fc20edf0..4eda6ed396 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index e0165dd767..8cb820cef5 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 0306ed17bc..b7c7c46b82 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -13021,6 +13021,10 @@ "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." } } }, diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index b5ef90856b..8ebeb5721c 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index e6302e980c..387acb67f0 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index e88483c1ab..dd953eeffb 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index cdf04594e4..cbb08287a8 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index bd22f12e29..edd071e301 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index fe37549ab6..2a61f79370 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 828175ae30..5b0a3a8073 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 28edb542e0..939a9d4ce7 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 13002cc69b..d878dea06d 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index f768f3e5d2..a2ce04cbfa 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13022,6 +13022,10 @@ "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." } } }, diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index a4d2d42bb1..4ed1b4d7f1 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -13020,6 +13020,10 @@ "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" } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 36fb5d0848..e0b2d64788 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 176eb328ff..8fc5babd76 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13010,6 +13010,10 @@ }, "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." } } }, diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b3bb087ec5..84591bccf1 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -594,6 +594,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ 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", + }, // ──────────────── CLI (5) ──────────────── { diff --git a/stryker.conf.json b/stryker.conf.json index 0be11313fa..a669fd57b8 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -371,6 +371,7 @@ "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", diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index ec5214a570..3786c9471f 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 57; +const EXPECTED_FEATURE_FLAG_COUNT = 58; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry 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/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 12fd868206..eec8201e2b 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, 57); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 58); }); }); From 611342609f30a3e4abe33dcbe68805ea64355fa2 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:46:57 +0200 Subject: [PATCH 18/36] fix(combo): return 502 for non-quota protected-priority stops (#13439) Behind the new `PROTECTED_PRIORITY_INFRA_502_ENABLED` flag (default off), protected-priority combo stops caused by provably non-quota infrastructure (provider circuit open, predictive-TTFT latency) surface as 502 instead of a quota-looking 503. Maintainer rework before merge (kept the idea, no default behavior change): - The original branch made 502 the default for every stop, including model lockouts and cooldowns, and removed the #8133/#1731 provider-wide skip for 401/5xx without a connection id; both are restored with their regression tests untouched. - Nineteen cases cover eight gate causes plus predictive latency, flag off and on. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13439-protected-priority-502.md | 1 + config/quality/file-size-baseline.json | 4 +- docs/reference/FEATURE_FLAGS.md | 7 +- .../services/combo/executeTargetAttempt.ts | 11 +- open-sse/services/combo/executeTargetGates.ts | 11 +- .../combo/protectedPriorityStopStatus.ts | 31 ++ src/i18n/messages/am.json | 4 + src/i18n/messages/ar.json | 4 + src/i18n/messages/az.json | 4 + src/i18n/messages/bg.json | 4 + src/i18n/messages/bn.json | 4 + src/i18n/messages/cs.json | 4 + src/i18n/messages/da.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/el.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/et.json | 4 + src/i18n/messages/fa.json | 4 + src/i18n/messages/fi.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ga.json | 4 + src/i18n/messages/gu.json | 4 + src/i18n/messages/ha.json | 4 + src/i18n/messages/he.json | 4 + src/i18n/messages/hi.json | 4 + src/i18n/messages/hr.json | 4 + src/i18n/messages/hu.json | 4 + src/i18n/messages/hy.json | 4 + src/i18n/messages/id.json | 4 + src/i18n/messages/ig.json | 4 + src/i18n/messages/it.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ka.json | 4 + src/i18n/messages/km.json | 4 + src/i18n/messages/kn.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/lt.json | 4 + src/i18n/messages/lv.json | 4 + src/i18n/messages/ml.json | 4 + src/i18n/messages/mr.json | 4 + src/i18n/messages/ms.json | 4 + src/i18n/messages/mt.json | 4 + src/i18n/messages/my.json | 4 + src/i18n/messages/ne.json | 4 + src/i18n/messages/nl.json | 4 + src/i18n/messages/no.json | 4 + src/i18n/messages/or.json | 4 + src/i18n/messages/pa.json | 4 + src/i18n/messages/phi.json | 4 + src/i18n/messages/pl.json | 4 + src/i18n/messages/pt-BR.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/ro.json | 4 + src/i18n/messages/ru.json | 4 + src/i18n/messages/si.json | 4 + src/i18n/messages/sk.json | 4 + src/i18n/messages/sl.json | 4 + src/i18n/messages/sr.json | 4 + src/i18n/messages/sv.json | 4 + src/i18n/messages/sw.json | 4 + src/i18n/messages/ta.json | 4 + src/i18n/messages/te.json | 4 + src/i18n/messages/th.json | 4 + src/i18n/messages/tr.json | 4 + src/i18n/messages/uk-UA.json | 4 + src/i18n/messages/ur.json | 4 + src/i18n/messages/uz.json | 4 + src/i18n/messages/vi.json | 4 + src/i18n/messages/yo.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + .../constants/featureFlagDefinitions.ts | 12 + stryker.conf.json | 1 + ...combo-terminal-status-policy-10501.test.ts | 10 +- ...otected-priority-stop-status-13439.test.ts | 317 ++++++++++++++++++ tests/unit/feature-flags-settings.test.ts | 2 +- .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 78 files changed, 659 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fixes/13439-protected-priority-502.md create mode 100644 open-sse/services/combo/protectedPriorityStopStatus.ts create mode 100644 tests/unit/combo/protected-priority-stop-status-13439.test.ts 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/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1d8d34b04e..66cf67d436 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_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).", @@ -433,6 +434,7 @@ "_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, @@ -449,7 +451,7 @@ "open-sse/services/accountFallback.ts": 2493, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4080, - "open-sse/services/combo/executeTargetAttempt.ts": 1223, + "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, diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 8e56aaaa51..3b466fd0b2 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 -58 flags across 6 categories. **Default** is the definition default — the value +59 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 (26) +### Runtime (27) | Key | Type | Default | Restart | Description | | ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -118,6 +118,7 @@ used when neither a DB override nor an environment variable is present. | `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) @@ -198,7 +199,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 58 flags + // ... all 59 flags ], "summary": { "total": 54, diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index 1d47679e32..2572dc2c20 100644 --- a/open-sse/services/combo/executeTargetAttempt.ts +++ b/open-sse/services/combo/executeTargetAttempt.ts @@ -97,6 +97,8 @@ 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; @@ -120,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; }; @@ -200,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" + ); } } } 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/src/i18n/messages/am.json b/src/i18n/messages/am.json index 3cae5d670d..e53a6fe326 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e2ff82e523..0789cf02d4 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 8fc4d00a13..1b6b723a6b 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index adebbc4086..dc4e850be6 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 28be75ea9a..dfc9077403 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index f492b6c79a..a20f3480e1 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 19c3f0efdf..1a8d85b473 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 5fa30efbc3..682c4f84f0 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index b2593fb696..e83a948257 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 26688cbc2c..418f932cbc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index d78a9c3df3..e316fc8d0c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 8e3ef1eaa2..947dc5d042 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index bab8ea3f0b..e4adad2a0a 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index ca0fe6a732..72d4833f0d 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index d07fa8abd7..118ab8663d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 7aba7e6fab..a9c5648e67 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 3bcafcaa71..e7b159c2e7 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 1439faeb11..7d3355e755 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 93f525cfbf..4b5a1db7cc 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 4ea5afb924..867efc690b 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 3bf2623806..bdf64ba513 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 6edbe88ad7..a5afd2b171 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 18d196f830..49341e0ff9 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 9cb2d993dd..5e329dfc37 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index f3a76a6a99..0c513e348c 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 736752b0b4..5f64ab49a3 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c1d729fb89..071387ef3c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index 6ce7ef156c..c98a865a19 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index ccdb84e1f8..3cc27e4736 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index b64ec807fa..363b1dcc68 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index abdf698b5d..0ba9026b72 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 05f138d0fc..5850b743f4 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 3ecffe6e0d..ae5bbd0ecd 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 19cb3d36b5..c0fbfdd61a 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 4ee8d83b35..17ab45f003 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index bdfae76329..dbacfbbcf5 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 4143bedd97..4dbfbc9945 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index fd3dee3f4f..02ccab45a5 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index fddd2e0c87..6c520ccae1 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index c66e68c213..048679e336 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 427c6b2ec2..a368cdd845 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index 4b91bef1ad..ac01b2f195 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index b033cedd00..d7c05c9dbc 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 309e91e251..314abb6d6a 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 7faa0bb350..0e68acb642 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index d7fc969916..dd448d1435 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13026,6 +13026,10 @@ "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." } } }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 379f56bd87..7dea5326f4 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13015,6 +13015,10 @@ "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." } } }, diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index aaced7b98e..47a7832d8e 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 010e4ea2a8..997ee90d11 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index b076befc47..cd4851d90e 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 4eda6ed396..fbc39f9d4e 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 8cb820cef5..419db35039 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index b7c7c46b82..e2a1f82274 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -13025,6 +13025,10 @@ "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." } } }, diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 8ebeb5721c..e97ffa7ae9 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 387acb67f0..b1d0fcbddd 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index dd953eeffb..9382cedbd0 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index cbb08287a8..667edd9e19 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index edd071e301..80d66e24e3 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 2a61f79370..355234fde2 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 5b0a3a8073..9398f06c84 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 939a9d4ce7..82c851d6e1 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index d878dea06d..c0ac0d00e7 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index a2ce04cbfa..f0a573b804 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13026,6 +13026,10 @@ "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ễ." } } }, diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index 4ed1b4d7f1..2aedf7d709 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -13024,6 +13024,10 @@ "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" } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e0b2d64788..c2318ee3bd 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 8fc5babd76..3a4fb9418b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13014,6 +13014,10 @@ "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." } } }, diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 84591bccf1..558c5f6ca2 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -606,6 +606,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ 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/stryker.conf.json b/stryker.conf.json index a669fd57b8..17863fcbda 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", 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/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/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 3786c9471f..098faf6931 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 58; +const EXPECTED_FEATURE_FLAG_COUNT = 59; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index eec8201e2b..7bc69c3c6d 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, 58); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 59); }); }); From defa0f07b2e79ff201cb9a61cd5e84f8f503398a Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:52:33 +0200 Subject: [PATCH 19/36] fix(routing): await stale provider-pin clears and gate swallowed errors plus fire-and-forget async (#13614) Stale provider-pin (`clearStaleLKGP`) clears are no longer silent: the fire-and-forget promise carries a `.catch` that warns with combo, comboId and executionKey, and a `check:routing-error-guard` npm script keeps the inventory of swallowed catches in the routing hot path from growing. Maintainer rework before merge (kept the idea, no default behavior change): - The awaited DB writes in the fallback loop were reverted (they added latency and SQLite lock exposure on every skip); the clear stays non-blocking. - The guard keys its allowlist by file + normalized catch body instead of line numbers (the PR's version broke on any edit) and is wired as an npm script only, not in CI; the unused stats counters were dropped. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13614-routing-error-guard.md | 1 + open-sse/services/combo.ts | 29 +- open-sse/services/combo/staleLkgpClear.ts | 44 +++ package.json | 1 + .../allowlist-routing-swallowed-catch.json | 372 ++++++++++++++++++ scripts/check/allowlist-void-async.json | 15 + scripts/check/check-routing-error-guard.mjs | 274 +++++++++++++ tests/unit/check-routing-error-guard.test.ts | 133 +++++++ .../unit/combo/stale-lkgp-clear-13614.test.ts | 100 +++++ 9 files changed, 943 insertions(+), 26 deletions(-) create mode 100644 changelog.d/fixes/13614-routing-error-guard.md create mode 100644 open-sse/services/combo/staleLkgpClear.ts create mode 100644 scripts/check/allowlist-routing-swallowed-catch.json create mode 100644 scripts/check/allowlist-void-async.json create mode 100644 scripts/check/check-routing-error-guard.mjs create mode 100644 tests/unit/check-routing-error-guard.test.ts create mode 100644 tests/unit/combo/stale-lkgp-clear-13614.test.ts 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/open-sse/services/combo.ts b/open-sse/services/combo.ts index 89ab50a142..8b8da3a0e2 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -129,6 +129,7 @@ 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 { clearStaleLKGP } from "./combo/staleLkgpClear.ts"; export { RESET_WINDOW_NAMES, QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; export { scoreAutoTargets, expandAutoComboCandidatePool }; @@ -175,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, 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/package.json b/package.json index 5230e1710e..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", 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-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/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/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, []); +}); From 53ed8c4745dacf6086339559f38800f719e49e95 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:10:52 +0200 Subject: [PATCH 20/36] fix(stream-recovery): order-aware in-flight tool-call detection behind off-by-default flag (#13633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` (default off), mid-stream continuation becomes tool-call safe: any tool call seen in the stream — in flight or finished — blocks a continuation, and an empty continuation stops after one attempt. Maintainer rework before merge (kept the idea, no default behavior change): - The empty-continuation short-circuit also ran with the flag off; it is now gated, so the flag-off path uses the whole budget exactly as before (regression test added). - The latch re-arm that let a continuation fire after a completed `finish_reason: tool_calls` is gone; index-less tool calls on multi-choice payloads are now blocked too; ~150 lines of dead trace plumbing removed. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13633-stream-recovery-toolcall-order.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/services/streamRecovery.ts | 41 ++++- src/i18n/messages/am.json | 3 + src/i18n/messages/ar.json | 3 + src/i18n/messages/az.json | 3 + src/i18n/messages/bg.json | 3 + src/i18n/messages/bn.json | 3 + src/i18n/messages/cs.json | 3 + src/i18n/messages/da.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/el.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/et.json | 3 + src/i18n/messages/fa.json | 3 + src/i18n/messages/fi.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ga.json | 3 + src/i18n/messages/gu.json | 3 + src/i18n/messages/ha.json | 3 + src/i18n/messages/he.json | 3 + src/i18n/messages/hi.json | 3 + src/i18n/messages/hr.json | 3 + src/i18n/messages/hu.json | 3 + src/i18n/messages/hy.json | 3 + src/i18n/messages/id.json | 3 + src/i18n/messages/ig.json | 3 + src/i18n/messages/it.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ka.json | 3 + src/i18n/messages/km.json | 3 + src/i18n/messages/kn.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/lt.json | 3 + src/i18n/messages/lv.json | 3 + src/i18n/messages/ml.json | 3 + src/i18n/messages/mr.json | 3 + src/i18n/messages/ms.json | 3 + src/i18n/messages/mt.json | 3 + src/i18n/messages/my.json | 3 + src/i18n/messages/ne.json | 3 + src/i18n/messages/nl.json | 3 + src/i18n/messages/no.json | 3 + src/i18n/messages/or.json | 3 + src/i18n/messages/pa.json | 3 + src/i18n/messages/phi.json | 3 + src/i18n/messages/pl.json | 3 + src/i18n/messages/pt-BR.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/ro.json | 3 + src/i18n/messages/ru.json | 3 + src/i18n/messages/si.json | 3 + src/i18n/messages/sk.json | 3 + src/i18n/messages/sl.json | 3 + src/i18n/messages/sr.json | 3 + src/i18n/messages/sv.json | 3 + src/i18n/messages/sw.json | 3 + src/i18n/messages/ta.json | 3 + src/i18n/messages/te.json | 3 + src/i18n/messages/th.json | 3 + src/i18n/messages/tr.json | 3 + src/i18n/messages/uk-UA.json | 3 + src/i18n/messages/ur.json | 3 + src/i18n/messages/uz.json | 3 + src/i18n/messages/vi.json | 3 + src/i18n/messages/yo.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + .../constants/featureFlagDefinitions.ts | 12 ++ stryker.conf.json | 1 + tests/unit/feature-flags-settings.test.ts | 17 +- .../unit/server-owned-tool-loop-flag.test.ts | 2 +- tests/unit/stream-recovery-toolcall.test.ts | 172 +++++++++++++++++- 74 files changed, 444 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/13633-stream-recovery-toolcall-order.md 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/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 3b466fd0b2..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 -59 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 (27) +### 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. | @@ -199,7 +200,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 59 flags + // ... all 60 flags ], "summary": { "total": 54, diff --git a/open-sse/services/streamRecovery.ts b/open-sse/services/streamRecovery.ts index 3a95e9a2a3..b891cb678c 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") { @@ -433,7 +448,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 +475,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 +513,23 @@ 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(); const emitCleanTerminal = (controller: ReadableStreamDefaultController) => { controller.enqueue( @@ -572,6 +604,13 @@ export function createRecoverableStream( 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; + } // The continuation truncated too — try again (bounded), else close cleanly so the // client never hangs waiting on a partial response. if (await tryContinue(controller)) return true; diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index e53a6fe326..e3310a39ec 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 0789cf02d4..0a9cec55ba 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -12978,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. عطل هذا للعملاء الذين يقبلون معرفات النماذج فقط." }, diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 1b6b723a6b..0f64f36188 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -12978,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." }, diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index dc4e850be6..894e89ceb2 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -12978,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. Деактивирайте това за клиенти, които приемат само идентификатори на модели." }, diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index dfc9077403..c2812aedd3 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -12978,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 গ্রহণ করে এমন ক্লায়েন্টদের জন্য এটি নিষ্ক্রিয় করুন।" }, diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index a20f3480e1..41168f4317 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -12978,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ů." }, diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 1a8d85b473..5ed7b52a32 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -12978,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." }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 682c4f84f0..ba6a0ba9d8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -12978,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." }, diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index e83a948257..60d7e0e4eb 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -12985,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. Απενεργοποιήστε το για πελάτες που δέχονται μόνο αναγνωριστικά μοντέλων." }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 418f932cbc..952b9d7892 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12985,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." }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e316fc8d0c..e2ecb77e0d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -12978,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." }, diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 947dc5d042..477a2c1872 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -12985,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." }, diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index e4adad2a0a..3936ffb372 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -12978,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. این را برای کلاینت‌هایی که فقط شناسه مدل را می‌پذیرند غیرفعال کنید." }, diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 72d4833f0d..747382aa86 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -12978,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." }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 118ab8663d..810908b273 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -12978,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." }, diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index a9c5648e67..7abdd92b38 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -12985,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." }, diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index e7b159c2e7..1abae7effd 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -12978,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 સ્વીકારતા ક્લાયન્ટ્સ માટે આને નિષ્ક્રિય કરો." }, diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 7d3355e755..067614c87a 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 4b5a1db7cc..3c0c6b974c 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -12978,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. השבת זאת עבור לקוחות המקבלים מזהי מודל בלבד." }, diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 867efc690b..71366e295f 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -12978,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 स्वीकार करते हैं।" }, diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index bdf64ba513..9fb0008554 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -12985,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." }, diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index a5afd2b171..3a5a561340 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -12978,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." }, diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 49341e0ff9..899f71cab6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5e329dfc37..68c93e871c 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -12978,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." }, diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 0c513e348c..68a874b3cf 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 5f64ab49a3..0423bbefe0 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -12978,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." }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 071387ef3c..cc32747df4 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -12978,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." }, diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index c98a865a19..da8081dfd2 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 3cc27e4736..e6ceb04f35 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -12985,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 ប៉ុណ្ណោះ។" }, diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index 363b1dcc68..91f2848d9f 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -12985,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ಗಳನ್ನು ಸ್ವೀಕರಿಸುವ ಕ್ಲೈಂಟ್ಗಳಿಗಾಗಿ ಇದನ್ನು ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಿ." }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0ba9026b72..8bb1a943af 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -12978,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만 허용하는 클라이언트의 경우 이 설정을 비활성화하세요." }, diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 5850b743f4..30b12f3c8e 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -12985,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." }, diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index ae5bbd0ecd..eac45c511c 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -12985,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." }, diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index c0fbfdd61a..cbb931d389 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -12985,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-കൾ മാത്രം സ്വീകരിക്കുന്ന ക്ലയന്റുകൾക്കായി ഇത് പ്രവർത്തനരഹിതമാക്കുക." }, diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 17ab45f003..3c6404eafd 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -12978,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 प्रतिसादांमध्ये प्रदर्शनासाठी अनुकूल नाव फील्ड समाविष्ट करा. केवळ मॉडेल आयडी स्वीकारणाऱ्या क्लायंटसाठी हे अक्षम करा." }, diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index dbacfbbcf5..9523b29ec2 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -12978,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." }, diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 4dbfbc9945..3c13aaaf21 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -12985,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." }, diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 02ccab45a5..d74f8bd325 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -12985,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 များအတွက် ၎င်းကို ပိတ်ပါ။" }, diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 6c520ccae1..08de787ae8 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -12985,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 मात्र स्वीकार गर्ने क्लाइन्टहरूका लागि यसलाई अक्षम गर्नुहोस्।" }, diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 048679e336..2ec3eae52e 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -12978,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." }, diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index a368cdd845..09784ae247 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -12978,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." }, diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index ac01b2f195..b3de2f2763 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -12985,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 ଗ୍ରହଣ କରୁଥିବା କ୍ଲାଏଣ୍ଟମାନଙ୍କ ପାଇଁ ଏହାକୁ ଅକ୍ଷମ କରନ୍ତୁ।" }, diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index d7c05c9dbc..399da303c0 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -12985,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 ਸਵੀਕਾਰ ਕਰਨ ਵਾਲੇ ਕਲਾਇੰਟਾਂ ਲਈ ਇਸਨੂੰ ਅਸਮਰੱਥ ਕਰੋ।" }, diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 314abb6d6a..86ff74d38c 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -12978,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." }, diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 0e68acb642..8dc1d43f63 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -12978,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." }, diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index dd448d1435..e847f282db 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -12986,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." }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 7dea5326f4..18e075cd32 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -12979,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." }, diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 47a7832d8e..b1654f46c5 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -12978,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." }, diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 997ee90d11..4503907861 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -12978,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." }, diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index cd4851d90e..2d8e40b949 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -12985,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 පමණක් පිළිගන්නා සේවාලාභීන් සඳහා මෙය අක්රිය කරන්න." }, diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index fbc39f9d4e..48dfa3b005 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -12978,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." }, diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 419db35039..c493b570c1 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -12985,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." }, diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index e2a1f82274..5335baf092 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -12985,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-ове модела." }, diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index e97ffa7ae9..af5e6a7249 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -12978,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." }, diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index b1d0fcbddd..6fdc8c4b57 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -12978,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." }, diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 9382cedbd0..dc2d2582eb 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -12978,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 பதில்களில் காட்சிக்கு ஏற்ற பெயர் புலங்களைச் சேர்க்கவும். மாடல் ஐடிகளை மட்டுமே ஏற்கும் கிளையண்டுகளுக்கு இதை முடக்கவும்." }, diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 667edd9e19..9ef01639cf 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -12978,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లను మాత్రమే ఆమోదించే క్లయింట్‌ల కోసం దీనిని నిలిపివేయండి." }, diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 80d66e24e3..dd1632b851 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -12978,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 โมเดลเท่านั้น" }, diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 355234fde2..cf8b325b8b 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -12978,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." }, diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 9398f06c84..6a22c6fa52 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -12978,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. Вимкніть це для клієнтів, які приймають лише ідентифікатори моделей." }, diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 82c851d6e1..9f281b3c99 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -12978,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 قبول کرتے ہیں۔" }, diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index c0ac0d00e7..8af8cd7c5d 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index f0a573b804..90b7c96e75 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -12986,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." }, diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index 2aedf7d709..e72f3c43a3 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -13028,6 +13028,9 @@ "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." } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index c2318ee3bd..bffe8f36ef 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -12978,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 的客户端,请禁用此项。" }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 3a4fb9418b..11691c6e64 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -12978,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 的用戶端請停用此項。" }, diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 558c5f6ca2..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", diff --git a/stryker.conf.json b/stryker.conf.json index 17863fcbda..9ad8f37a7f 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -413,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/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 098faf6931..d6486a0624 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 59; +const EXPECTED_FEATURE_FLAG_COUNT = 60; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -151,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/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 7bc69c3c6d..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, 59); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 60); }); }); 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"); + }); +}); From 931c9f9b9d41932107627fa13451c94e614b9a0e Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:19:10 +0200 Subject: [PATCH 21/36] fix(stream-recovery): log recovery traces with continuation attempt (#13650) Recovery traces for mid-stream continuation: one `onContinueOutcome` hook reports suffix stitched, overlap rejected, terminal, empty, no-stream and refused (with reason), logged through `chatCore` at debug level; warn is reserved for the cases where recovery gives up. Maintainer rework before merge (kept the idea, no default behavior change): - The original logged a warn-level latch line on every streamed tool call; nominal and tool-call streams are now silent, and the existing `mid-stream continuation attempt N/4` line keeps its format. - `chatCore.ts` ends 3 lines shorter than the tip, so the baseline bump the PR carried was removed; the wiring is tested through a real `handleChatCore` continuation. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13650-recovery-trace-logging.md | 1 + open-sse/handlers/chatCore.ts | 7 +- .../handlers/chatCore/recoveryTraceLogging.ts | 59 ++++++ open-sse/services/streamRecovery.ts | 55 ++++- ...hatcore-stream-recovery-log-wiring.test.ts | 114 +++++++++++ .../stream-recovery-trace-logging.test.ts | 189 ++++++++++++++++++ 6 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/13650-recovery-trace-logging.md create mode 100644 open-sse/handlers/chatCore/recoveryTraceLogging.ts create mode 100644 tests/unit/chatcore-stream-recovery-log-wiring.test.ts create mode 100644 tests/unit/stream-recovery-trace-logging.test.ts 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/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/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/services/streamRecovery.ts b/open-sse/services/streamRecovery.ts index b891cb678c..3bd95e5123 100644 --- a/open-sse/services/streamRecovery.ts +++ b/open-sse/services/streamRecovery.ts @@ -350,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; @@ -371,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. */ @@ -531,6 +548,16 @@ export function createRecoverableStream( (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( encoder.encode('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n') @@ -541,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 { @@ -554,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. @@ -586,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; @@ -598,9 +635,19 @@ 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; } @@ -658,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/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/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/); + } +}); From 08fe9e5117ef8fc8533ff100d67c82b70a462553 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:22:51 +0200 Subject: [PATCH 22/36] fix(usage): skip billing for estimated token usage, bill real output only on partial estimates (#13686) Estimated token usage is now visible to operators: usage a provider marks as `estimated` carries an internal marker through extraction and the call log records `_omniroute.usageEstimated: true` on the logged response. Maintainer rework before merge (kept the idea, no default behavior change): - Billing is unchanged: the original skipped cost/budget/quota-share for estimated usage, which would have let streams without upstream usage and eight web executors spend $0 against API-key budgets; that part is reverted and no opt-in flag was added because it cannot be made budget-safe. - Both open-sse TS2345 errors, the client-visible `estimated_prompt_tokens` field and the `as unknown as` casts are gone; four real `handleChatCore` cases assert the marker and unchanged spend. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13686-estimated-usage-guard.md | 1 + open-sse/handlers/chatCore/attemptLogging.ts | 4 + .../chatCore/quotaShareConsumption.ts | 5 +- open-sse/handlers/usageExtractor.ts | 5 +- open-sse/utils/usageTracking.ts | 30 ++- .../estimated-usage-billing-guard.test.ts | 199 ++++++++++++++++++ 6 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/13686-estimated-usage-guard.md create mode 100644 tests/unit/estimated-usage-billing-guard.test.ts 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/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/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/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/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); +}); From 87d9d82b37800a493613e3dbd521c92921d240fc Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:49:06 +0200 Subject: [PATCH 23/36] fix(sse): fail over to sibling connection on stream early EOF (#13153) Behind `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` (default off): after the bounded same-connection retry is spent, a stream that closed early fails over exactly once to a sibling connection. Maintainer rework before merge (kept the idea, no default behavior change): - The PR's own failover test was red on its head: the `/v1/chat/completions` route's early-stream keepalive dropped the `X-OmniRoute-Selected-Connection-Id` header on the first cold request. Tests now drive `handleChat()` directly; the assertion was kept. - "One hop" was one hop per connection (a 3-connection pool made 4 dispatches); it is now a single sibling hop per request, and when the pool runs out the original `STREAM_EARLY_EOF` 502 is returned instead of a generic `bad_gateway`, so combo-level detection keeps working. The source-regex timeout test became a behavioral one; flag description in all 59 locales. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13153-early-eof-sibling-failover.md | 1 + config/quality/file-size-baseline.json | 4 +- docs/reference/FEATURE_FLAGS.md | 9 +- src/i18n/messages/am.json | 3 + src/i18n/messages/ar.json | 3 + src/i18n/messages/az.json | 3 + src/i18n/messages/bg.json | 3 + src/i18n/messages/bn.json | 3 + src/i18n/messages/cs.json | 3 + src/i18n/messages/da.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/el.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/et.json | 3 + src/i18n/messages/fa.json | 3 + src/i18n/messages/fi.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ga.json | 3 + src/i18n/messages/gu.json | 3 + src/i18n/messages/ha.json | 3 + src/i18n/messages/he.json | 3 + src/i18n/messages/hi.json | 3 + src/i18n/messages/hr.json | 3 + src/i18n/messages/hu.json | 3 + src/i18n/messages/hy.json | 3 + src/i18n/messages/id.json | 3 + src/i18n/messages/ig.json | 3 + src/i18n/messages/it.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ka.json | 3 + src/i18n/messages/km.json | 3 + src/i18n/messages/kn.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/lt.json | 3 + src/i18n/messages/lv.json | 3 + src/i18n/messages/ml.json | 3 + src/i18n/messages/mr.json | 3 + src/i18n/messages/ms.json | 3 + src/i18n/messages/mt.json | 3 + src/i18n/messages/my.json | 3 + src/i18n/messages/ne.json | 3 + src/i18n/messages/nl.json | 3 + src/i18n/messages/no.json | 3 + src/i18n/messages/or.json | 3 + src/i18n/messages/pa.json | 3 + src/i18n/messages/phi.json | 3 + src/i18n/messages/pl.json | 3 + src/i18n/messages/pt-BR.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/ro.json | 3 + src/i18n/messages/ru.json | 3 + src/i18n/messages/si.json | 3 + src/i18n/messages/sk.json | 3 + src/i18n/messages/sl.json | 3 + src/i18n/messages/sr.json | 3 + src/i18n/messages/sv.json | 3 + src/i18n/messages/sw.json | 3 + src/i18n/messages/ta.json | 3 + src/i18n/messages/te.json | 3 + src/i18n/messages/th.json | 3 + src/i18n/messages/tr.json | 3 + src/i18n/messages/uk-UA.json | 3 + src/i18n/messages/ur.json | 3 + src/i18n/messages/uz.json | 3 + src/i18n/messages/vi.json | 3 + src/i18n/messages/yo.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + .../constants/featureFlagDefinitions.ts | 12 + src/sse/handlers/chat.ts | 30 +- src/sse/handlers/chatHelpers.ts | 15 + stryker.conf.json | 1 + .../chat-stream-early-eof-failover.test.ts | 364 ++++++++++++++++++ tests/unit/feature-flags-settings.test.ts | 18 +- .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 76 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/13153-early-eof-sibling-failover.md create mode 100644 tests/unit/chat-stream-early-eof-failover.test.ts diff --git a/changelog.d/fixes/13153-early-eof-sibling-failover.md b/changelog.d/fixes/13153-early-eof-sibling-failover.md new file mode 100644 index 0000000000..b55cc45ea6 --- /dev/null +++ b/changelog.d/fixes/13153-early-eof-sibling-failover.md @@ -0,0 +1 @@ +- **fix(sse):** fail over once to a sibling connection on stream early EOF (the original `STREAM_EARLY_EOF` 502 is kept when no sibling can serve the request), gated behind `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` (default off) ([#13153](https://github.com/diegosouzapw/OmniRoute/pull/13153)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 66cf67d436..4db0a02179 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -9,6 +9,8 @@ "_rebaseline_2026_09_11_12945_image_only_model_guard": "PR #12945 own growth: open-sse/handlers/imageGeneration.ts 3259->3293 (+35/-1). The image-only-model guard the PR adds to clear its base-red: the handler now recognises a model that only serves image generation and answers before the chat path can mis-route it. Irreducible at this call site; the predicate itself lives outside the file. Landed as its own PR rather than on #12945 because that branch has a live worktree in another session and pushing to it would pull the branch out from under whoever is working it. Covered by the batch run: 203/208 with the 5 remaining failures reproducing on the pure tip.", "_rebaseline_2026_09_11_mergebatch_v3851_diego": "/merge-batch 2026-09-11 (v3.8.51), owner batch. open-sse/handlers/chatCore.ts 6144->6146 (+2): #13278 requires a Responses-shaped body before the native OpenAI-compatible passthrough (+1) and #13276 stops the reactive-compaction log from claiming a compaction when compression is disabled (+2/-1). Both are guard conditions at existing call sites, no new branching structure. open-sse/utils/stream.ts is deliberately NOT rebaselined: already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). Covered by 256 assertions across the batch's test files (246 node:test + 10 vitest).", "_rebaseline_2026_09_11_mergebatch_v3851_houminxi": "/merge-batch 2026-09-11 (v3.8.51), batch by HouMinXi. Final combined values, set on the first PR merged so every intermediate state is covered. open-sse/handlers/chatCore.ts 6036->6144: #13069 routes the non-streaming leg through the same provider-failure classification, model lockout and credential-refresh path the streaming leg already used (+443/-340 = +103 net; it extracts applyProviderFailureClassification and wires both legs to it, which is what #13043 reported missing), plus #13050 stamping that the client asked for SSE before the web_search fallback flips stream off (+6) and #13038 threading the dispatched target index (+3). src/sse/services/auth.ts 3488->3542: #13017 adds the explicit-pin one-shot probe for a recoverable inactive row with its 60s storm gate (+42 net) and #13061 makes a grok-cli 402 a connection-wide shared-wallet signal instead of a per-model billing miss (+12 net). src/sse/handlers/chat.ts 2458->2462: #13038 (+5). open-sse/services/combo/executeTargetAttempt.ts 1205->1212: #13006 feeds the 402 it already classified into the quota cache instead of dropping it (+7). open-sse/services/accountFallback.ts 2468->2469: #13060 adds the Cline re-auth phrase to OAUTH_INVALID_TOKEN_SIGNALS (+1). open-sse/utils/stream.ts is deliberately NOT rebaselined: already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). The file also carried \"open-sse/handlers/chatCore.ts\" twice (6026 and 6036); JSON keeps the last, so the first was dead weight any writer could have picked instead. Collapsed to one entry at the live value. Covered by 531 focused assertions across the batch's 46 test files.", + "_rebaseline_2026_09_15_13153_early_eof_one_hop": "PR #13153 rework on tip c0f92ec9 (release/v3.8.51): src/sse/handlers/chat.ts 2471->2490 (+19; 2462 on the pure tip, so +28 in total for the PR). +8 is not this PR's logic: the tip carries an unformatted one-line comboTargetPassesKeyModelPolicy call from #12886 that the pre-commit lint-staged prettier reflows as soon as any commit touches chat.ts. +11 is the rework: a per-request earlyEofOriginal slot (declaration + 2-line comment), a one-line early return of the original STREAM_EARLY_EOF 502 at the top of the no-credentials branch, and the multi-line gate that bounds the flag-gated sibling hop to one hop per request. All behind STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED (default off). Covered by tests/unit/chat-stream-early-eof-failover.test.ts (7/7: flag off, one hop, singleton, sibling failure, forced pin, readiness timeout).", + "_rebaseline_2026_09_12_13153_early_eof_flag_gate_v2": "PR #13153 own growth on tip 00f16a27: src/sse/handlers/chat.ts 2462->2471 (+9, check-file-size split-newline counting: block import+condition+8-line failover on the new base, which already carries +1 from #13038). The post-retry early-EOF terminal path gains a guarded exclude-and-continue block (import line + gate condition calling isEarlyEofSiblingFailoverOn() from chatHelpers.ts, where the fail-safe flag read lives under cap, + warn/exclude/state-carryover/continue). Irreducible call-site wiring at the existing terminal chokepoint; the sibling hop itself is unchanged from the PR first commit. Covered by tests/unit/chat-stream-early-eof-failover.test.ts (6/6, incl. flag-off terminal case).", "_rebaseline_2026_09_11_12358_chat_pipeline_custom_node": "PR #12358 own test growth: tests/integration/chat-pipeline.test.ts 1648->1736 (+88). One new integration case, \"#11884 chat pipeline sends a custom node's edited Chat API type upstream\": it seeds a custom OpenAI-compatible node with an edited Chat/Responses API type, stubs fetch, drives handleChatCore and asserts the upstream request carries the live connection setting rather than the format baked into the node id at creation. Irreducible at this layer — the point of the test is the full route-to-upstream path, which is what #11884 regressed. Nothing else in the file changed. Covered by the case itself plus tests/unit/chat-helpers.test.ts (28/28).", "_rebaseline_2026_09_10_12975_rotation_correlation_id": "PR #12975 own growth: open-sse/executors/base.ts 1751->1753 (+2) and open-sse/handlers/chatCore.ts 6021->6024 (+3). The opencode rotation lines carry the request correlationId: one optional ExecuteInput field and one correlationId argument at each of the three executor.execute call sites in handleChatCore. Irreducible plumbing at existing call sites; the rotation logic itself lives in open-sse/executors/opencode.ts and the new leaf predicates (under cap). Covered by tests/unit/opencode-transient-rotation.test.ts and tests/unit/chat-correlation-id-exhaustion.test.ts.", "_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode": "/merge-batch 2026-09-11 (v3.8.51), PRs #13141, #13146 and #12975 by maxmad64bis. src/sse/services/auth.ts 3450->3488 (+38): #13146 adds the narrow ruleScope===model branch to markAccountUnavailable (gated on status 400; every other status keeps its path) plus the HONORS_RULE_LOCK_SCOPE_PROVIDERS opencode entry, taking it to 3464; #12975 then adds buildExhaustionOptions so the exhaustion log lines carry the request correlationId (+24). open-sse/services/accountFallback.ts 2467->2468 (+1): #13141 routes hasFutureRateLimitUntil through the tolerant epoch normalizer; #13146 is net zero there (+16/-16). open-sse/executors/base.ts 1751->1753 (+2): #12975 adds the optional ExecuteInput.correlationId field with its doc comment. src/sse/handlers/chat.ts is NOT rebaselined: #12975 threads correlationId through the three executor call sites (+2) but the file lands at 2452, still under its existing 2458 freeze. open-sse/utils/stream.ts is deliberately NOT rebaselined either: it is already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). No new branching beyond the two guarded branches named above. Covered by tests/unit/combo-predicates-epoch-cooldown.test.ts, opencode-400-model-unavailable.test.ts, agentrouter-error-rules.test.ts, opencode-transient-rotation.test.ts and chat-correlation-id-exhaustion.test.ts.", @@ -483,7 +485,7 @@ "src/shared/components/RequestLoggerV2.tsx": 1718, "src/shared/constants/providers/apikey/gateways.ts": 1502, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2462, + "src/sse/handlers/chat.ts": 2490, "src/sse/services/auth.ts": 3556, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656, diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 1b7d4a1a58..bac50a5992 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 -60 flags across 6 categories. **Default** is the definition default — the value +61 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 (28) +### Runtime (29) | Key | Type | Default | Restart | Description | | ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -106,6 +106,7 @@ used when neither a DB override nor an environment variable is present. | `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. | +| `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` | boolean | `false` | | Fail over once to a sibling connection when an SSE stream closes before emitting any useful frame and the bounded same-connection retry is spent; with no usable sibling the original `STREAM_EARLY_EOF` 502 is returned. Off by default: early-EOF stays terminal after the same-connection retry. | | `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. | @@ -200,10 +201,10 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 60 flags + // ... all 61 flags ], "summary": { - "total": 54, + "total": 56, "active": 0, "inactive": 0, "overriddenByDb": 0, diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index e3310a39ec..b2c68dffaa 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 0a9cec55ba..301f71b81f 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "الانتقال مرة واحدة إلى اتصال شقيق عندما يُغلق التدفق قبل إرسال أي إطار مفيد وتُستنفد إعادة المحاولة المحدودة على الاتصال نفسه. إذا لم يتوفر اتصال شقيق صالح، يُعاد خطأ early-EOF الأصلي. عند التعطيل: يبقى early-EOF نهائيًا بعد إعادة المحاولة." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "تضمين حقول الأسماء المناسبة للعرض في استجابات /v1/models. عطل هذا للعملاء الذين يقبلون معرفات النماذج فقط." }, diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 0f64f36188..65dae94a1d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Axın heç bir faydalı çərçivə göndərmədən bağlandıqda və eyni bağlantıda məhdud təkrar cəhd tükəndikdə bir dəfə qardaş bağlantıya keçin. Uyğun qardaş bağlantı yoxdursa, ilkin early-EOF xətası qaytarılır. Söndürülüb: early-EOF təkrar cəhddən sonra son nəticə olaraq qalır." + }, "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." }, diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 894e89ceb2..12c57eb2d8 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Еднократно превключване към сродна връзка, когато потокът се затвори, преди да изпрати полезен фрейм, и ограниченият повторен опит на същата връзка е изчерпан. Ако няма използваема сродна връзка, се връща оригиналната грешка early-EOF. Изключено: early-EOF остава окончателен след повторния опит." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Включване на лесни за четене полета за имена в отговорите на /v1/models. Деактивирайте това за клиенти, които приемат само идентификатори на модели." }, diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index c2812aedd3..12bd9ba46e 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "স্ট্রিম কোনো উপযোগী ফ্রেম পাঠানোর আগেই বন্ধ হলে এবং একই সংযোগে সীমিত পুনঃচেষ্টা শেষ হয়ে গেলে একবার একটি সহোদর সংযোগে ফেইলওভার করুন। ব্যবহারযোগ্য সহোদর সংযোগ না থাকলে মূল early-EOF ত্রুটি ফেরত দেওয়া হয়। বন্ধ থাকলে: পুনঃচেষ্টার পরে early-EOF চূড়ান্ত থাকে।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models প্রতিক্রিয়াগুলিতে প্রদর্শন-বান্ধব নামের ক্ষেত্রগুলি অন্তর্ভুক্ত করুন। শুধুমাত্র মডেল ID গ্রহণ করে এমন ক্লায়েন্টদের জন্য এটি নিষ্ক্রিয় করুন।" }, diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 41168f4317..0415053170 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednorázově přepnout na sesterské připojení, když se stream uzavře před odesláním jakéhokoli užitečného rámce a omezený opakovaný pokus na stejném připojení je vyčerpán. Pokud není k dispozici použitelné sesterské připojení, vrátí se původní chyba early-EOF. Vypnuto: early-EOF zůstává po opakovaném pokusu konečný." + }, "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ů." }, diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 5ed7b52a32..185b2910a1 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Skift én gang over til en søsterforbindelse, når en stream lukker, før den har sendt en brugbar frame, og det begrænsede genforsøg på samme forbindelse er brugt. Uden en brugbar søsterforbindelse returneres den oprindelige early-EOF-fejl. Fra: early-EOF forbliver endelig efter genforsøget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkluder visningsvenlige navnefelter i /v1/models-svar. Deaktivér dette for klienter, der kun accepterer model-id'er." }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ba6a0ba9d8..848c2b8aee 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Einmaliger Failover auf eine Geschwisterverbindung, wenn ein Stream schließt, bevor er einen nützlichen Frame gesendet hat, und der begrenzte Wiederholungsversuch auf derselben Verbindung aufgebraucht ist. Ohne nutzbare Geschwisterverbindung wird der ursprüngliche early-EOF-Fehler zurückgegeben. Aus: early-EOF bleibt nach dem Wiederholungsversuch endgültig." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Benutzerfreundliche Namensfelder in /v1/models-Antworten einschließen. Deaktivieren Sie dies für Clients, die nur Modell-IDs akzeptieren." }, diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 60d7e0e4eb..4c38ca2e5c 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Μία εναλλαγή σε αδελφή σύνδεση όταν μια ροή κλείνει πριν στείλει οποιοδήποτε χρήσιμο πλαίσιο και η περιορισμένη επανάληψη στην ίδια σύνδεση έχει εξαντληθεί. Χωρίς διαθέσιμη αδελφή σύνδεση επιστρέφεται το αρχικό σφάλμα early-EOF. Ανενεργό: το early-EOF παραμένει τελικό μετά την επανάληψη." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Συμπερίληψη φιλικών προς εμφάνιση πεδίων ονόματος στις αποκρίσεις /v1/models. Απενεργοποιήστε το για πελάτες που δέχονται μόνο αναγνωριστικά μοντέλων." }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 952b9d7892..3154d1cc55 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e2ecb77e0d..241c443908 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Conmutar una sola vez a una conexión hermana cuando un stream se cierra antes de emitir cualquier trama útil y se agotó el reintento limitado en la misma conexión. Si no hay una conexión hermana utilizable, se devuelve el error early-EOF original. Desactivado: early-EOF sigue siendo terminal tras el reintento." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 477a2c1872..52745b1b68 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Lülitu üks kord ümber sõsarühendusele, kui voog sulgub enne ühegi kasuliku kaadri saatmist ja sama ühenduse piiratud kordusproov on ära kasutatud. Kasutatava sõsarühenduse puudumisel tagastatakse algne early-EOF viga. Väljas: early-EOF jääb pärast kordusproovi lõplikuks." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Kaasa /v1/models vastustesse kuvamiseks sobivad nimeväljad. Keela see klientide puhul, mis aktsepteerivad ainult mudeli-ID-sid." }, diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 3936ffb372..dc7f955a5a 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "یک بار به یک اتصال هم‌خانواده منتقل شوید وقتی جریان پیش از ارسال هر فریم مفیدی بسته می‌شود و تلاش مجدد محدود روی همان اتصال تمام شده است. اگر اتصال هم‌خانواده قابل‌استفاده‌ای نباشد، خطای اصلی early-EOF برگردانده می‌شود. خاموش: early-EOF پس از تلاش مجدد نهایی می‌ماند." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "گنجاندن فیلدهای نام مناسب برای نمایش در پاسخ‌های /v1/models. این را برای کلاینت‌هایی که فقط شناسه مدل را می‌پذیرند غیرفعال کنید." }, diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 747382aa86..8aa208a9c8 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Siirry kerran rinnakkaisyhteyteen, kun virta sulkeutuu ennen yhdenkään hyödyllisen kehyksen lähettämistä ja saman yhteyden rajattu uusintayritys on käytetty. Jos käyttökelpoista rinnakkaisyhteyttä ei ole, palautetaan alkuperäinen early-EOF-virhe. Pois: early-EOF on lopullinen uusintayrityksen jälkeen." + }, "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." }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 810908b273..8d49f1294c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Basculer une seule fois vers une connexion sœur lorsqu'un flux se ferme avant d'émettre la moindre trame utile et que la nouvelle tentative limitée sur la même connexion est épuisée. Sans connexion sœur utilisable, l'erreur early-EOF d'origine est renvoyée. Désactivé : early-EOF reste terminal après la nouvelle tentative." + }, "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." }, diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 7abdd92b38..70180cc0a8 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Aistrigh uair amháin chuig nasc deirfiúr nuair a dhúntar sruth sula seoltar fráma úsáideach ar bith agus nuair atá an athiarracht theoranta ar an nasc céanna ídithe. Mura bhfuil nasc deirfiúr inúsáidte ann, seoltar an earráid early-EOF bhunaidh ar ais. As: fanann early-EOF críochnaitheach tar éis na hathiarrachta." + }, "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." }, diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 1abae7effd..2239beca53 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "જ્યારે સ્ટ્રીમ કોઈ ઉપયોગી ફ્રેમ મોકલતા પહેલાં બંધ થઈ જાય અને એ જ કનેક્શન પરનો મર્યાદિત પુનઃપ્રયાસ પૂરો થઈ જાય ત્યારે એક વાર સહોદર કનેક્શન પર ફેલઓવર કરો. ઉપયોગી સહોદર કનેક્શન ન હોય તો મૂળ early-EOF ભૂલ પરત કરવામાં આવે છે. બંધ: પુનઃપ્રયાસ પછી early-EOF અંતિમ રહે છે." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models પ્રતિસાદોમાં પ્રદર્શન-અનુકૂળ નામ ફીલ્ડ્સ શામેલ કરો. ફક્ત મોડલ IDs સ્વીકારતા ક્લાયન્ટ્સ માટે આને નિષ્ક્રિય કરો." }, diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 067614c87a..000844ada9 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 3c0c6b974c..6a034e5a10 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "מעבר חד-פעמי לחיבור אח כאשר זרם נסגר לפני ששלח מסגרת שימושית כלשהי והניסיון החוזר המוגבל באותו חיבור מוצה. אם אין חיבור אח שמיש, מוחזרת שגיאת early-EOF המקורית. כבוי: early-EOF נשאר סופי לאחר הניסיון החוזר." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "הכללת שדות שם ידידותיים לתצוגה בתגובות של /v1/models. השבת זאת עבור לקוחות המקבלים מזהי מודל בלבד." }, diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 71366e295f..79753a8bd3 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "जब स्ट्रीम कोई उपयोगी फ़्रेम भेजे बिना बंद हो जाए और उसी कनेक्शन पर सीमित पुनःप्रयास समाप्त हो जाए, तब एक बार सहोदर कनेक्शन पर फ़ेलओवर करें। उपयोग योग्य सहोदर कनेक्शन न होने पर मूल early-EOF त्रुटि लौटाई जाती है। बंद: पुनःप्रयास के बाद early-EOF अंतिम रहता है।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिक्रियाओं में प्रदर्शन-अनुकूल नाम फ़ील्ड शामिल करें। उन क्लाइंट्स के लिए इसे अक्षम करें जो केवल मॉडल ID स्वीकार करते हैं।" }, diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 9fb0008554..295870b25b 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednom se prebaci na srodnu vezu kada se tok zatvori prije slanja ijednog korisnog okvira, a ograničeni ponovni pokušaj na istoj vezi je iskorišten. Ako nema upotrebljive srodne veze, vraća se izvorna early-EOF pogreška. Isključeno: early-EOF ostaje konačan nakon ponovnog pokušaja." + }, "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." }, diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 3a5a561340..3e4e72f51d 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Egyszeri átállás egy testvérkapcsolatra, ha az adatfolyam még az első hasznos keret előtt lezárul, és az ugyanazon a kapcsolaton végzett korlátozott újrapróbálkozás elfogyott. Használható testvérkapcsolat hiányában az eredeti early-EOF hiba kerül visszaadásra. Kikapcsolva: az early-EOF az újrapróbálkozás után végleges marad." + }, "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." }, diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 899f71cab6..6494d703d6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 68c93e871c..53b664ad31 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Alihkan sekali ke koneksi saudara ketika stream tertutup sebelum mengirim frame yang berguna dan percobaan ulang terbatas pada koneksi yang sama sudah habis. Jika tidak ada koneksi saudara yang dapat digunakan, error early-EOF asli dikembalikan. Nonaktif: early-EOF tetap final setelah percobaan ulang." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sertakan bidang nama yang mudah dibaca dalam respons /v1/models. Nonaktifkan ini untuk klien yang hanya menerima ID model." }, diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 68a874b3cf..1b7a13e90a 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 0423bbefe0..870bf6ef27 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Esegui un solo failover verso una connessione sorella quando uno stream si chiude prima di emettere qualsiasi frame utile e il nuovo tentativo limitato sulla stessa connessione è esaurito. Senza una connessione sorella utilizzabile viene restituito l'errore early-EOF originale. Disattivato: early-EOF resta terminale dopo il nuovo tentativo." + }, "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." }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index cc32747df4..176f90a4f3 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ストリームが有用なフレームを送出する前に閉じ、同一接続での上限付き再試行も使い切った場合に、兄弟接続へ一度だけフェイルオーバーします。使用可能な兄弟接続がない場合は、元の early-EOF エラーを返します。オフ: 再試行後の early-EOF はそのまま終了扱いです。" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index da8081dfd2..c4b22a11a9 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index e6ceb04f35..2f3ffba763 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ប្តូរទៅការតភ្ជាប់បងប្អូនម្តងគត់ នៅពេល stream បិទមុនពេលបញ្ចេញ frame ដែលមានប្រយោជន៍ណាមួយ ហើយការព្យាយាមឡើងវិញដែលមានកំណត់លើការតភ្ជាប់ដដែលត្រូវបានប្រើអស់ហើយ។ បើគ្មានការតភ្ជាប់បងប្អូនដែលអាចប្រើបាន កំហុស early-EOF ដើមនឹងត្រូវបានត្រឡប់មកវិញ។ បិទ៖ early-EOF នៅតែជាចុងក្រោយបន្ទាប់ពីការព្យាយាមឡើងវិញ។" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "រួមបញ្ចូលវាលឈ្មោះដែលងាយស្រួលបង្ហាញក្នុង response របស់ /v1/models។ បិទវាសម្រាប់ clients ដែលទទួលយកតែ model IDs ប៉ុណ្ណោះ។" }, diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index 91f2848d9f..fba32261a7 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ಸ್ಟ್ರೀಮ್ ಯಾವುದೇ ಉಪಯುಕ್ತ ಫ್ರೇಮ್ ಕಳುಹಿಸುವ ಮೊದಲೇ ಮುಚ್ಚಿದಾಗ ಮತ್ತು ಅದೇ ಸಂಪರ್ಕದ ಸೀಮಿತ ಮರುಪ್ರಯತ್ನ ಮುಗಿದಿದ್ದಾಗ ಒಮ್ಮೆ ಸಹೋದರ ಸಂಪರ್ಕಕ್ಕೆ ಫೇಲ್‌ಓವರ್ ಮಾಡಿ. ಬಳಸಬಹುದಾದ ಸಹೋದರ ಸಂಪರ್ಕ ಇಲ್ಲದಿದ್ದರೆ ಮೂಲ early-EOF ದೋಷವನ್ನು ಹಿಂತಿರುಗಿಸಲಾಗುತ್ತದೆ. ಆಫ್: ಮರುಪ್ರಯತ್ನದ ನಂತರ early-EOF ಅಂತಿಮವಾಗಿಯೇ ಉಳಿಯುತ್ತದೆ." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ಪ್ರತಿಕ್ರಿಯೆಗಳಲ್ಲಿ ಪ್ರದರ್ಶನಕ್ಕೆ ಸೂಕ್ತವಾದ ಹೆಸರು ಕ್ಷೇತ್ರಗಳನ್ನು ಸೇರಿಸಿ. ಕೇವಲ ಮಾದರಿ IDಗಳನ್ನು ಸ್ವೀಕರಿಸುವ ಕ್ಲೈಂಟ್ಗಳಿಗಾಗಿ ಇದನ್ನು ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಿ." }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 8bb1a943af..55d6d4a9df 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "스트림이 유용한 프레임을 보내기 전에 닫히고 같은 연결에서의 제한된 재시도도 소진되면 형제 연결로 한 번만 페일오버합니다. 사용할 수 있는 형제 연결이 없으면 원래의 early-EOF 오류를 반환합니다. 끄기: 재시도 후 early-EOF는 그대로 종료로 처리됩니다." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models 응답에 표시용 이름 필드를 포함합니다. 모델 ID만 허용하는 클라이언트의 경우 이 설정을 비활성화하세요." }, diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 30b12f3c8e..e71a97208a 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Vieną kartą perjungti į giminingą ryšį, kai srautas užsidaro neišsiuntęs jokio naudingo kadro ir ribotas pakartotinis bandymas tame pačiame ryšyje išnaudotas. Jei tinkamo giminingo ryšio nėra, grąžinama pradinė early-EOF klaida. Išjungta: early-EOF po pakartotinio bandymo lieka galutinis." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Į /v1/models atsakymus įtraukti patogiam rodymui skirtus pavadinimų laukus. Išjunkite tai klientams, kurie priima tik modelių ID." }, diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index eac45c511c..8c20c7f331 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Vienreiz pārslēgties uz radniecīgu savienojumu, ja straume aizveras, pirms nosūtīts kāds noderīgs kadrs, un ierobežotais atkārtotais mēģinājums tajā pašā savienojumā ir izlietots. Ja nav izmantojama radniecīga savienojuma, tiek atgriezta sākotnējā early-EOF kļūda. Izslēgts: early-EOF pēc atkārtotā mēģinājuma paliek galīgs." + }, "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." }, diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index cbb931d389..d6b017c189 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ഉപയോഗപ്രദമായ ഒരു ഫ്രെയിമും അയയ്ക്കുന്നതിന് മുമ്പ് സ്ട്രീം അടയുകയും അതേ കണക്ഷനിലെ പരിമിതമായ പുനഃശ്രമം തീരുകയും ചെയ്യുമ്പോൾ ഒരിക്കൽ ഒരു സഹോദര കണക്ഷനിലേക്ക് ഫെയിൽഓവർ ചെയ്യുക. ഉപയോഗിക്കാവുന്ന സഹോദര കണക്ഷൻ ഇല്ലെങ്കിൽ യഥാർത്ഥ early-EOF പിശക് തിരികെ നൽകും. ഓഫ്: പുനഃശ്രമത്തിന് ശേഷം early-EOF അന്തിമമായി തുടരും." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models പ്രതികരണങ്ങളിൽ പ്രദർശനത്തിന് അനുയോജ്യമായ നാമ ഫീൽഡുകൾ ഉൾപ്പെടുത്തുക. മോഡൽ ID-കൾ മാത്രം സ്വീകരിക്കുന്ന ക്ലയന്റുകൾക്കായി ഇത് പ്രവർത്തനരഹിതമാക്കുക." }, diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 3c6404eafd..21c5196dce 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "स्ट्रीम कोणतीही उपयुक्त फ्रेम पाठवण्यापूर्वी बंद झाल्यास आणि त्याच कनेक्शनवरील मर्यादित पुनर्प्रयत्न संपल्यास एकदा सहोदर कनेक्शनवर फेलओव्हर करा. वापरण्यायोग्य सहोदर कनेक्शन नसल्यास मूळ early-EOF त्रुटी परत केली जाते. बंद: पुनर्प्रयत्नानंतर early-EOF अंतिम राहतो." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिसादांमध्ये प्रदर्शनासाठी अनुकूल नाव फील्ड समाविष्ट करा. केवळ मॉडेल आयडी स्वीकारणाऱ्या क्लायंटसाठी हे अक्षम करा." }, diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 9523b29ec2..7cef88b648 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Tukar sekali ke sambungan adik-beradik apabila strim ditutup sebelum menghantar sebarang bingkai berguna dan cubaan semula terhad pada sambungan yang sama telah habis. Tanpa sambungan adik-beradik yang boleh digunakan, ralat early-EOF asal dikembalikan. Mati: early-EOF kekal muktamad selepas cubaan semula." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sertakan medan nama mesra paparan dalam respons /v1/models. Nyahdayakan ini untuk pelanggan yang hanya menerima ID model." }, diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 3c13aaaf21..424a633b5a 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Aqleb darba waħda għal konnessjoni oħt meta fluss jingħalaq qabel ma jibgħat xi frame utli u l-attentat mill-ġdid limitat fuq l-istess konnessjoni jkun intuża. Jekk ma jkunx hemm konnessjoni oħt li tista' tintuża, jintbagħat lura l-iżball early-EOF oriġinali. Mitfi: early-EOF jibqa' finali wara l-attentat mill-ġdid." + }, "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." }, diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index d74f8bd325..4a5b8d50d5 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "stream သည် အသုံးဝင်သော frame တစ်ခုမျှ မထုတ်မီ ပိတ်သွားပြီး ချိတ်ဆက်မှုတစ်ခုတည်းပေါ်ရှိ ကန့်သတ်ထားသော ပြန်လည်ကြိုးစားမှုလည်း ကုန်သွားပါက ညီအစ်ကိုချိတ်ဆက်မှုသို့ တစ်ကြိမ်သာ failover လုပ်ပါ။ အသုံးပြုနိုင်သော ညီအစ်ကိုချိတ်ဆက်မှု မရှိပါက မူလ early-EOF အမှားကို ပြန်ပေးပါသည်။ ပိတ်ထားလျှင်: ပြန်လည်ကြိုးစားပြီးနောက် early-EOF သည် နောက်ဆုံးအဖြစ် ရှိနေပါသည်။" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models response များတွင် ဖတ်ရှုရလွယ်ကူသော name field များကို ထည့်သွင်းပါ။ Model ID များကိုသာ လက်ခံသော client များအတွက် ၎င်းကို ပိတ်ပါ။" }, diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 08de787ae8..f9a1e9c081 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "स्ट्रिमले कुनै उपयोगी फ्रेम पठाउनुअघि नै बन्द भएमा र उही जडानमा सीमित पुनःप्रयास सकिएमा एक पटक सहोदर जडानमा फेलओभर गर्नुहोस्। प्रयोगयोग्य सहोदर जडान नभएमा मूल early-EOF त्रुटि फिर्ता गरिन्छ। बन्द: पुनःप्रयासपछि early-EOF अन्तिम रहन्छ।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिक्रियाहरूमा प्रदर्शनमैत्री नाम फिल्डहरू समावेश गर्नुहोस्। मोडेल ID मात्र स्वीकार गर्ने क्लाइन्टहरूका लागि यसलाई अक्षम गर्नुहोस्।" }, diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 2ec3eae52e..69b0eda1e9 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Schakel eenmaal over naar een zusterverbinding wanneer een stream sluit voordat er een bruikbaar frame is verzonden en de beperkte nieuwe poging op dezelfde verbinding is opgebruikt. Zonder bruikbare zusterverbinding wordt de oorspronkelijke early-EOF-fout teruggegeven. Uit: early-EOF blijft na de nieuwe poging definitief." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Voeg weergavevriendelijke naamvelden toe aan /v1/models-responsen. Schakel dit uit voor clients die alleen model-ID's accepteren." }, diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 09784ae247..2dfc51a835 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Bytt én gang over til en søskentilkobling når en strøm lukkes før den har sendt en nyttig ramme, og det begrensede nye forsøket på samme tilkobling er brukt opp. Uten en brukbar søskentilkobling returneres den opprinnelige early-EOF-feilen. Av: early-EOF forblir endelig etter det nye forsøket." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkluder visningsvennlige navnefelt i /v1/models-svar. Deaktiver dette for klienter som kun godtar modell-ID-er." }, diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index b3de2f2763..eaf3aff9cf 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ଷ୍ଟ୍ରିମ୍ କୌଣସି ଉପଯୋଗୀ ଫ୍ରେମ୍ ପଠାଇବା ପୂର୍ବରୁ ବନ୍ଦ ହେଲେ ଏବଂ ସେହି ସଂଯୋଗରେ ସୀମିତ ପୁନଃପ୍ରଚେଷ୍ଟା ଶେଷ ହୋଇଗଲେ ଥରେ ଏକ ସହୋଦର ସଂଯୋଗକୁ ଫେଲଓଭର କରନ୍ତୁ। ବ୍ୟବହାରଯୋଗ୍ୟ ସହୋଦର ସଂଯୋଗ ନଥିଲେ ମୂଳ early-EOF ତ୍ରୁଟି ଫେରାଇ ଦିଆଯାଏ। ବନ୍ଦ: ପୁନଃପ୍ରଚେଷ୍ଟା ପରେ early-EOF ଅନ୍ତିମ ରହେ।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ପ୍ରତିକ୍ରିୟାଗୁଡ଼ିକରେ ପ୍ରଦର୍ଶନ-ଅନୁକୂଳ ନାମ ଫିଲ୍ଡଗୁଡ଼ିକୁ ସାମିଲ କରନ୍ତୁ। କେବଳ ମଡେଲ୍ ID ଗ୍ରହଣ କରୁଥିବା କ୍ଲାଏଣ୍ଟମାନଙ୍କ ପାଇଁ ଏହାକୁ ଅକ୍ଷମ କରନ୍ତୁ।" }, diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 399da303c0..2eba360743 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ਜਦੋਂ ਸਟ੍ਰੀਮ ਕੋਈ ਉਪਯੋਗੀ ਫ੍ਰੇਮ ਭੇਜਣ ਤੋਂ ਪਹਿਲਾਂ ਬੰਦ ਹੋ ਜਾਵੇ ਅਤੇ ਉਸੇ ਕਨੈਕਸ਼ਨ 'ਤੇ ਸੀਮਤ ਮੁੜ-ਕੋਸ਼ਿਸ਼ ਖਤਮ ਹੋ ਜਾਵੇ, ਤਾਂ ਇੱਕ ਵਾਰ ਸਹੋਦਰ ਕਨੈਕਸ਼ਨ 'ਤੇ ਫੇਲਓਵਰ ਕਰੋ। ਵਰਤੋਂਯੋਗ ਸਹੋਦਰ ਕਨੈਕਸ਼ਨ ਨਾ ਹੋਣ 'ਤੇ ਮੂਲ early-EOF ਗਲਤੀ ਵਾਪਸ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਬੰਦ: ਮੁੜ-ਕੋਸ਼ਿਸ਼ ਤੋਂ ਬਾਅਦ early-EOF ਅੰਤਿਮ ਰਹਿੰਦਾ ਹੈ।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ਜਵਾਬਾਂ ਵਿੱਚ ਪ੍ਰਦਰਸ਼ਨ-ਅਨੁਕੂਲ ਨਾਮ ਫੀਲਡਾਂ ਸ਼ਾਮਲ ਕਰੋ। ਸਿਰਫ਼ ਮਾਡਲ IDs ਸਵੀਕਾਰ ਕਰਨ ਵਾਲੇ ਕਲਾਇੰਟਾਂ ਲਈ ਇਸਨੂੰ ਅਸਮਰੱਥ ਕਰੋ।" }, diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 86ff74d38c..9a863eb9a8 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Lumipat nang minsan sa isang kapatid na koneksyon kapag nagsara ang stream bago magpadala ng anumang kapaki-pakinabang na frame at nagamit na ang limitadong muling pagsubok sa parehong koneksyon. Kung walang magagamit na kapatid na koneksyon, ibinabalik ang orihinal na error na early-EOF. Naka-off: nananatiling pinal ang early-EOF pagkatapos ng muling pagsubok." + }, "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." }, diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 8dc1d43f63..498a9540a9 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednorazowo przełącz na siostrzane połączenie, gdy strumień zamknie się przed wysłaniem jakiejkolwiek użytecznej ramki, a ograniczona ponowna próba na tym samym połączeniu została wykorzystana. Bez użytecznego siostrzanego połączenia zwracany jest pierwotny błąd early-EOF. Wyłączone: early-EOF pozostaje ostateczny po ponownej próbie." + }, "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." }, diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index e847f282db..420ffcfb79 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -12989,6 +12989,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Faz failover uma única vez para uma conexão irmã quando um stream fecha antes de emitir qualquer frame útil e a nova tentativa limitada na mesma conexão já foi usada. Sem conexão irmã utilizável, o erro early-EOF original é retornado. Desligado: o early-EOF continua terminal após a nova tentativa." + }, "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." }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 18e075cd32..7e64a830ac 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -12982,6 +12982,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Fazer failover uma única vez para uma ligação irmã quando um stream fecha antes de emitir qualquer frame útil e a nova tentativa limitada na mesma ligação já foi usada. Sem ligação irmã utilizável, é devolvido o erro early-EOF original. Desligado: o early-EOF mantém-se terminal após a nova tentativa." + }, "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." }, diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index b1654f46c5..820b3e1a0d 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Comută o singură dată pe o conexiune soră când un flux se închide înainte de a emite vreun cadru util și reîncercarea limitată pe aceeași conexiune a fost consumată. Fără o conexiune soră utilizabilă, se returnează eroarea early-EOF originală. Dezactivat: early-EOF rămâne final după reîncercare." + }, "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." }, diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 4503907861..12f6e6184c 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Однократно переключиться на соседнее подключение, если поток закрылся, не отправив ни одного полезного фрейма, а ограниченная повторная попытка на том же подключении исчерпана. Если подходящего соседнего подключения нет, возвращается исходная ошибка early-EOF. Выкл.: early-EOF остаётся окончательным после повторной попытки." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 2d8e40b949..46e03fc1cf 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ප්‍රවාහය කිසිදු ප්‍රයෝජනවත් රාමුවක් යැවීමට පෙර වැසී, එකම සම්බන්ධතාවයේ සීමිත නැවත උත්සාහය ද අවසන් වූ විට වරක් සහෝදර සම්බන්ධතාවයකට මාරු වන්න. භාවිත කළ හැකි සහෝදර සම්බන්ධතාවයක් නොමැති නම් මුල් early-EOF දෝෂය ආපසු ලබා දේ. අක්‍රිය: නැවත උත්සාහයෙන් පසු early-EOF අවසාන ලෙස පවතී." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ප්රතිචාරවල ප්රදර්ශනයට හිතකර නාම ක්ෂේත්ර ඇතුළත් කරන්න. ආකෘති ID පමණක් පිළිගන්නා සේවාලාභීන් සඳහා මෙය අක්රිය කරන්න." }, diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 48dfa3b005..5405e2efd0 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednorazovo prepnúť na sesterské pripojenie, keď sa stream zatvorí pred odoslaním akéhokoľvek užitočného rámca a obmedzený opakovaný pokus na tom istom pripojení je vyčerpaný. Ak nie je k dispozícii použiteľné sesterské pripojenie, vráti sa pôvodná chyba early-EOF. Vypnuté: early-EOF zostáva po opakovanom pokuse konečný." + }, "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." }, diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index c493b570c1..e271354bc4 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Enkrat preklopi na sestrsko povezavo, ko se tok zapre, preden pošlje kakršen koli uporaben okvir, in je omejeni ponovni poskus na isti povezavi porabljen. Če uporabne sestrske povezave ni, se vrne izvirna napaka early-EOF. Izklopljeno: early-EOF po ponovnem poskusu ostane dokončen." + }, "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." }, diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 5335baf092..4be757e4a6 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -12988,6 +12988,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Једном пребаци на сродну везу када се ток затвори пре слања иједног корисног оквира, а ограничени поновни покушај на истој вези је искоришћен. Ако нема употребљиве сродне везе, враћа се изворна early-EOF грешка. Искључено: early-EOF остаје коначан након поновног покушаја." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Укључи поља са именом прилагођеним за приказ у одговорима /v1/models. Онемогући ово за клијенте који прихватају само ID-ове модела." }, diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index af5e6a7249..95597147fb 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Växla en gång över till en syskonanslutning när en ström stängs innan den har skickat någon användbar ram och det begränsade nya försöket på samma anslutning är förbrukat. Utan en användbar syskonanslutning returneras det ursprungliga early-EOF-felet. Av: early-EOF förblir slutgiltigt efter det nya försöket." + }, "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." }, diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 6fdc8c4b57..7659856c24 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Hamia mara moja kwenye muunganisho ndugu wakati mkondo unafungwa kabla ya kutuma fremu yoyote yenye manufaa na jaribio la marudio lenye kikomo kwenye muunganisho uleule limekwisha. Bila muunganisho ndugu unaoweza kutumika, hitilafu asili ya early-EOF hurejeshwa. Imezimwa: early-EOF inabaki ya mwisho baada ya jaribio la marudio." + }, "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." }, diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index dc2d2582eb..6ebf58a045 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ஸ்ட்ரீம் பயனுள்ள எந்த ஃப்ரேமையும் அனுப்பும் முன் மூடப்பட்டு, அதே இணைப்பில் வரம்பிடப்பட்ட மறுமுயற்சியும் தீர்ந்துவிட்டால், ஒருமுறை சகோதர இணைப்புக்கு ஃபெயில்ஓவர் செய்யவும். பயன்படுத்தக்கூடிய சகோதர இணைப்பு இல்லையெனில் அசல் early-EOF பிழை திருப்பியனுப்பப்படும். முடக்கம்: மறுமுயற்சிக்குப் பிறகு early-EOF இறுதியாகவே இருக்கும்." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models பதில்களில் காட்சிக்கு ஏற்ற பெயர் புலங்களைச் சேர்க்கவும். மாடல் ஐடிகளை மட்டுமே ஏற்கும் கிளையண்டுகளுக்கு இதை முடக்கவும்." }, diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 9ef01639cf..cb56704770 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "స్ట్రీమ్ ఉపయోగకరమైన ఏ ఫ్రేమ్‌నూ పంపకముందే మూసుకుపోయి, అదే కనెక్షన్‌పై పరిమిత పునఃప్రయత్నం కూడా అయిపోతే, ఒకసారి సోదర కనెక్షన్‌కు ఫెయిల్‌ఓవర్ చేయండి. ఉపయోగించదగిన సోదర కనెక్షన్ లేకపోతే అసలు early-EOF లోపం తిరిగి ఇవ్వబడుతుంది. ఆఫ్: పునఃప్రయత్నం తర్వాత early-EOF తుది ఫలితంగానే ఉంటుంది." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ప్రతిస్పందనలలో ప్రదర్శనకు అనుకూలమైన పేరు ఫీల్డ్‌లను చేర్చండి. మోడల్ IDలను మాత్రమే ఆమోదించే క్లయింట్‌ల కోసం దీనిని నిలిపివేయండి." }, diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index dd1632b851..e562496c41 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "สลับไปยังการเชื่อมต่อพี่น้องหนึ่งครั้งเมื่อสตรีมปิดก่อนส่งเฟรมที่มีประโยชน์ใด ๆ และการลองใหม่แบบจำกัดบนการเชื่อมต่อเดิมถูกใช้ไปแล้ว หากไม่มีการเชื่อมต่อพี่น้องที่ใช้งานได้ ระบบจะส่งคืนข้อผิดพลาด early-EOF เดิม ปิด: early-EOF ยังคงเป็นผลสิ้นสุดหลังการลองใหม่" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "รวมฟิลด์ชื่อที่แสดงผลได้ง่ายในการตอบกลับ /v1/models ปิดใช้งานตัวเลือกนี้สำหรับไคลเอนต์ที่ยอมรับเฉพาะ ID โมเดลเท่านั้น" }, diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index cf8b325b8b..1c7ab39db8 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Akış herhangi bir yararlı çerçeve göndermeden kapandığında ve aynı bağlantıdaki sınırlı yeniden deneme tükendiğinde bir kez kardeş bağlantıya geçiş yapın. Kullanılabilir kardeş bağlantı yoksa özgün early-EOF hatası döndürülür. Kapalı: early-EOF yeniden denemeden sonra nihai kalır." + }, "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." }, diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 6a22c6fa52..52b648d099 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Одноразово перемкнутися на сусіднє підключення, якщо потік закрився, не надіславши жодного корисного фрейму, а обмежену повторну спробу на тому самому підключенні вичерпано. Якщо придатного сусіднього підключення немає, повертається початкова помилка early-EOF. Вимкнено: early-EOF залишається остаточним після повторної спроби." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Включати зручні для відображення поля назв у відповіді /v1/models. Вимкніть це для клієнтів, які приймають лише ідентифікатори моделей." }, diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 9f281b3c99..215567a40b 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "جب اسٹریم کوئی مفید فریم بھیجنے سے پہلے بند ہو جائے اور اسی کنکشن پر محدود دوبارہ کوشش ختم ہو چکی ہو تو ایک بار کسی ہم رشتہ کنکشن پر فیل اوور کریں۔ قابلِ استعمال ہم رشتہ کنکشن نہ ہونے پر اصل early-EOF خرابی واپس کی جاتی ہے۔ بند: دوبارہ کوشش کے بعد early-EOF حتمی رہتا ہے۔" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models کے جوابات میں ڈسپلے کے لیے موزوں نام کے فیلڈز شامل کریں۔ ان کلائنٹس کے لیے اسے غیر فعال کریں جو صرف ماڈل IDs قبول کرتے ہیں۔" }, diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 8af8cd7c5d..2efade4cf3 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 90b7c96e75..05158d9a25 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -12989,6 +12989,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Chuyển dự phòng một lần sang kết nối anh em khi luồng dừng trước khi phát bất kỳ khung hữu ích nào và lần thử lại có giới hạn trên cùng kết nối đã cạn. Nếu không có kết nối anh em dùng được, lỗi early-EOF ban đầu sẽ được trả về. Tắt: early-EOF vẫn kết thúc sau 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." }, diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index e72f3c43a3..016c628d2d 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -13031,6 +13031,9 @@ }, "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." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bffe8f36ef..2048f44d28 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "当流在发出任何有用帧之前关闭,且同一连接上的有限重试已用尽时,故障转移一次到同级连接。若没有可用的同级连接,则返回原始的 early-EOF 错误。关闭:重试后 early-EOF 仍为终止结果。" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "在 /v1/models 响应中包含易于显示的名称字段。对于仅接受模型 ID 的客户端,请禁用此项。" }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 11691c6e64..a9eb2bbfec 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -12981,6 +12981,9 @@ "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." }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "當串流在送出任何有用訊框之前關閉,且同一連線上的有限重試已用盡時,容錯移轉一次到同級連線。若沒有可用的同級連線,則回傳原始的 early-EOF 錯誤。關閉:重試後 early-EOF 仍為終止結果。" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "在 /v1/models 回應中包含顯示友善的名稱欄位。對於僅接受模型 ID 的用戶端請停用此項。" }, diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index dbb87ac26a..e125527bfe 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -461,6 +461,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED", + label: "Early-EOF Sibling Failover", + description: + "Fail over once to a sibling connection when an SSE stream closes before emitting any useful frame and the bounded same-connection retry is spent; with no usable sibling the original STREAM_EARLY_EOF 502 is returned. Off by default: early-EOF stays terminal after the same-connection retry.", + descriptionI18nKey: "featureFlagStreamEarlyEofSiblingFailoverEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, { key: "MODEL_CATALOG_INCLUDE_NAMES", label: "Model Catalog Names", diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index d3ff15d444..4ab6826fd6 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -100,6 +100,7 @@ import { safeLogEvents, applyExecutorProxyToInfo, shouldRetryStreamEarlyEof, + isEarlyEofSiblingFailoverOn, withSessionHeader, withSelectedConnectionHeader, withCorrelationId, @@ -1014,7 +1015,15 @@ async function handleChatImplementation( if (isComboLiveTest) return true; // #12886: combo-name allow-list must not skip inner targets (#9057 still // checks auto/* / disableNonPublic via comboTargetPassesKeyModelPolicy). - if (!(await comboTargetPassesKeyModelPolicy({ apiKey, apiKeyInfo, requestedModelStr: resolvedModelStr, targetModelStr: modelString, isModelAllowedForKey }))) { + if ( + !(await comboTargetPassesKeyModelPolicy({ + apiKey, + apiKeyInfo, + requestedModelStr: resolvedModelStr, + targetModelStr: modelString, + isModelAllowedForKey, + })) + ) { return false; } @@ -1637,6 +1646,9 @@ async function handleSingleModelChat( // re-attempt to exactly one for the whole request. Declared outside both retry // loops so it can never reset and loop. let streamEarlyEofRetries = 0; + // STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED: at most ONE sibling hop per request. Keeps the + // original early-EOF 502 so an exhausted sibling pool surfaces it verbatim (combo detection). + let earlyEofOriginal: Response | null = null; const sameAccountTransportRetries = new Map(); const occupancySessionKey = runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? `request:${randomUUID()}`; @@ -1709,6 +1721,7 @@ async function handleSingleModelChat( "allExpired" in credentials || !credentials.connectionId ) { + if (earlyEofOriginal) return earlyEofOriginal; if (credentials?.allRateLimited) { const retryDecision = getCooldownAwareRetryDecision({ retryAfter: credentials.retryAfter, @@ -2093,6 +2106,21 @@ async function handleSingleModelChat( // Stream readiness timeout is an upstream stall after an HTTP response was received, // not an account/quota failure. Do NOT mark the account unavailable here. + if ( + isTerminalStreamEarlyEof && + !hasForcedConnection && + !earlyEofOriginal && + isEarlyEofSiblingFailoverOn() + ) { + // Retry spent and nothing emitted yet: one hop to a sibling (routing only, no mark). + log.warn("STREAM", `${provider}/${model} early-EOF retry exhausted — trying one sibling`); + earlyEofOriginal = withSelectedConnectionHeader( + result.response, + credentials.connectionId + ); + excludedConnectionIds.add(credentials.connectionId); + continue; + } return withSelectedConnectionHeader(result.response, credentials?.connectionId); } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index e2277ae1ff..9632236691 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -46,6 +46,7 @@ import { } from "../../shared/utils/circuitBreaker"; import { classify429FromError, type FailureKind } from "../../shared/utils/classify429"; import { resolveUseUpstream429BreakerHints } from "../../shared/utils/providerHints"; +import { isFeatureFlagEnabled } from "../../shared/utils/featureFlags"; import { logProxyEvent } from "../../lib/proxyLogger"; import { logTranslationEvent } from "../../lib/translatorEvents"; @@ -925,6 +926,20 @@ export function shouldRetryStreamEarlyEof( return errorCode === "STREAM_EARLY_EOF" && attempt < STREAM_EARLY_EOF_MAX_RETRIES; } +// The sibling hop widens the terminal/failover boundary, so it ships off +// behind STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED until observed live. +export function isEarlyEofSiblingFailoverOn(): boolean { + try { + return isFeatureFlagEnabled("STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function decideProxyResolutionFailure( err: unknown, env: { PROXY_FAIL_OPEN?: string } = process.env diff --git a/stryker.conf.json b/stryker.conf.json index 9ad8f37a7f..016657d32d 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -126,6 +126,7 @@ "tests/unit/chat-context-relay.test.ts", "tests/unit/chat-cooldown-aware-retry.test.ts", "tests/unit/chat-helpers.test.ts", + "tests/unit/chat-stream-early-eof-failover.test.ts", "tests/unit/chatgpt-web-codex.test.ts", "tests/unit/chat-route-coverage.test.ts", "tests/unit/chat-route-edge-cases.test.ts", diff --git a/tests/unit/chat-stream-early-eof-failover.test.ts b/tests/unit/chat-stream-early-eof-failover.test.ts new file mode 100644 index 0000000000..a477562b26 --- /dev/null +++ b/tests/unit/chat-stream-early-eof-failover.test.ts @@ -0,0 +1,364 @@ +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"; + +// Stream early EOF sibling failover (direct single-model path, #13153). +// +// When the upstream opens an SSE stream but closes it before emitting any +// useful frame, the readiness gate surfaces 502 STREAM_EARLY_EOF. The bounded +// same-connection retry (#3758) makes one plain re-attempt. With +// STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED on, once that retry is spent the +// request makes exactly ONE hop to a sibling connection; if no sibling can serve +// it, the ORIGINAL STREAM_EARLY_EOF 502 is surfaced unchanged so combo-level +// detection (isStreamEarlyEofErrorBody) keeps working. No account is ever marked +// unavailable for an early close, STREAM_READINESS_TIMEOUT stays terminal, and +// with the flag off (the default) the release behavior is unchanged. +// +// These cases drive handleChat() directly rather than the /v1/chat/completions +// route: the route wraps streaming requests in withEarlyStreamKeepalive, which +// commits a synthetic 200 SSE response (dropping the handler's status and +// headers) whenever the handler takes longer than 2 s — a cold first request or +// a loaded machine is enough, and the failover assertions would then observe the +// keepalive wrapper instead of the failover. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-early-eof-failover-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; +process.env.DASHBOARD_PASSWORD = ""; +process.env.INITIAL_PASSWORD = ""; +delete process.env.JWT_SECRET; +if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = `test-early-eof-failover-${Date.now()}`; +} +// A short readiness window so the STREAM_READINESS_TIMEOUT case resolves quickly. +// Every other stub body is static, so readiness settles on the first read. +process.env.STREAM_READINESS_TIMEOUT_MS = "1000"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { handleChat } = await import("../../src/sse/handlers/chat.ts"); +const { initTranslators } = await import("../../open-sse/translator/index.ts"); +const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); + +const originalFetch = globalThis.fetch; + +async function flushBackgroundWork() { + await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setImmediate(resolve)); +} + +async function resetStorage() { + clearInflight(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + resetAllCircuitBreakers(); + initTranslators(); +} + +type SeededConnection = { id: string; apiKey: string }; + +async function seedConnection(name: string, apiKey: string): Promise { + const row = (await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name, + apiKey, + isActive: true, + testStatus: "active", + })) as { id: string }; + return { id: row.id, apiKey }; +} + +// An SSE body that closes with zero non-ping frames: the exact input shape +// the readiness gate turns into 502 STREAM_EARLY_EOF. +function pingOnlyStreamResponse(): Response { + return new Response(`: keepalive\n\ndata: ${JSON.stringify({ type: "ping" })}\n\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +// An SSE body that stays open without a useful frame: STREAM_READINESS_TIMEOUT. +function stalledStreamResponse(): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + +function successStreamResponse(content: string): Response { + return new Response( + `data: ${JSON.stringify({ + id: "chatcmpl-early-eof-failover", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], + })}\n\ndata: ${JSON.stringify({ + id: "chatcmpl-early-eof-failover", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + +function unauthorizedResponse(): Response { + return new Response( + JSON.stringify({ error: { message: "Incorrect API key provided", type: "invalid_request" } }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); +} + +function streamRequest(extraHeaders: Record = {}) { + // A per-request nonce keeps the semantic cache and request dedup out of the way. + const nonce = `early-eof-failover-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + ...extraHeaders, + }, + body: JSON.stringify({ + model: "openai/gpt-4.1", + messages: [{ role: "user", content: `Reply with OK only. ${nonce}` }], + max_tokens: 16, + stream: true, + temperature: 0, + }), + }); +} + +// Every outbound fetch carries the connection's own credential, so the stub +// attributes each dispatch to a connection by its Authorization header. +function stubFetch(dispatches: string[], handler: (auth: string, callIndex: number) => Response) { + globalThis.fetch = (async (_url: unknown, init: { headers?: unknown }) => { + const headers = new Headers((init?.headers ?? {}) as HeadersInit); + const auth = headers.get("authorization") ?? ""; + const callIndex = dispatches.length; + dispatches.push(auth); + return handler(auth, callIndex); + }) as typeof fetch; +} + +function authOf(connection: SeededConnection): string { + return `Bearer ${connection.apiKey}`; +} + +function errorCodeOf(bodyText: string): string | undefined { + try { + return (JSON.parse(bodyText) as { error?: { code?: string } })?.error?.code; + } catch { + return undefined; + } +} + +async function assertNotMarked(connection: SeededConnection, label: string) { + const row = (await providersDb.getProviderConnectionById(connection.id)) as Record< + string, + unknown + > | null; + assert.ok(row, `the ${label} connection must still exist`); + const until = (row.rateLimitedUntil as string | null | undefined) ?? null; + assert.ok( + until === null || new Date(String(until)).getTime() <= Date.now(), + `expected no cooldown on the ${label} connection, got rateLimitedUntil=${until}` + ); + assert.notEqual(row.testStatus, "unavailable", `the ${label} connection must not be unavailable`); +} + +const SIBLING_FAILOVER_FLAG = "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED"; +const ORIGINAL_SIBLING_FAILOVER_FLAG = process.env[SIBLING_FAILOVER_FLAG]; + +function setSiblingFailoverFlag(enabled: boolean) { + if (enabled) process.env[SIBLING_FAILOVER_FLAG] = "true"; + else delete process.env[SIBLING_FAILOVER_FLAG]; +} + +test.beforeEach(async () => { + globalThis.fetch = originalFetch; + setSiblingFailoverFlag(true); + await resetStorage(); +}); + +test.afterEach(async () => { + await flushBackgroundWork(); + globalThis.fetch = originalFetch; + if (ORIGINAL_SIBLING_FAILOVER_FLAG === undefined) delete process.env[SIBLING_FAILOVER_FLAG]; + else process.env[SIBLING_FAILOVER_FLAG] = ORIGINAL_SIBLING_FAILOVER_FLAG; +}); + +test.after(async () => { + await flushBackgroundWork(); + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("flag off (default): early EOF stays terminal after the same-connection retry, nothing marked", async () => { + setSiblingFailoverFlag(false); + const connA = await seedConnection("openai-flagoff-a", "sk-failover-flagoff-a"); + const connB = await seedConnection("openai-flagoff-b", "sk-failover-flagoff-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + // 1 initial + 1 bounded same-connection retry, then the terminal 502 — no + // sibling hop despite an eligible sibling. + assert.equal(dispatches.length, 2, `expected 2 dispatches, got ${dispatches.length}`); + const firstAuth = dispatches[0]; + assert.equal(dispatches[1], firstAuth, "the bounded retry stays on the same connection"); + const first = firstAuth === authOf(connA) ? connA : connB; + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF"); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), first.id); + await assertNotMarked(connA, "first"); + await assertNotMarked(connB, "sibling"); +}); + +test("flag on: fails over to the sibling after the bounded retry, without marking any account", async () => { + const connA = await seedConnection("openai-failover-a", "sk-failover-conn-a"); + const connB = await seedConnection("openai-failover-b", "sk-failover-conn-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, (_auth, callIndex) => + callIndex < 2 ? pingOnlyStreamResponse() : successStreamResponse("OK") + ); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + assert.equal(dispatches.length, 3, `expected 1 + retry + sibling, got ${dispatches.length}`); + const first = dispatches[0] === authOf(connA) ? connA : connB; + const sibling = first === connA ? connB : connA; + assert.equal(dispatches[1], authOf(first), "the bounded retry stays on the same connection"); + assert.equal(dispatches[2], authOf(sibling), "the spent retry must hop to the sibling"); + assert.equal(response.status, 200, `expected 200, got ${response.status}: ${bodyText}`); + assert.equal( + response.headers.get("X-OmniRoute-Selected-Connection-Id"), + sibling.id, + "the response must carry the sibling as the selected connection" + ); + assert.match(bodyText, /OK/, "the client must receive the sibling's content"); + assert.ok(!bodyText.includes("STREAM_EARLY_EOF"), "the client must not see the early-EOF 502"); + await assertNotMarked(first, "first"); + await assertNotMarked(sibling, "sibling"); +}); + +test("flag on: bounds the failover to exactly one sibling hop per request", async () => { + const conns = [ + await seedConnection("openai-onehop-a", "sk-failover-onehop-a"), + await seedConnection("openai-onehop-b", "sk-failover-onehop-b"), + await seedConnection("openai-onehop-c", "sk-failover-onehop-c"), + ]; + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + // 1 initial + 1 same-connection retry + 1 sibling hop. The sibling's own early + // close is terminal: no second hop to the third connection. + assert.equal( + dispatches.length, + 3, + `expected exactly one hop, got ${dispatches.length} dispatches` + ); + assert.equal(dispatches[1], dispatches[0], "the bounded retry stays on the same connection"); + assert.notEqual(dispatches[2], dispatches[0], "the single hop must go to a sibling"); + const sibling = conns.find((conn) => authOf(conn) === dispatches[2]); + assert.ok(sibling, "the hop must reach a seeded sibling"); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF", "the terminal body keeps its code"); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), sibling.id); + for (const conn of conns) await assertNotMarked(conn, conn.id); +}); + +test("flag on: a singleton pool surfaces the original STREAM_EARLY_EOF 502", async () => { + const conn = await seedConnection("openai-singleton", "sk-failover-singleton"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + assert.equal(dispatches.length, 2, `expected 2 dispatches, got ${dispatches.length}`); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal( + errorCodeOf(bodyText), + "STREAM_EARLY_EOF", + `the original early-EOF body must survive an empty sibling pool: ${bodyText}` + ); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), conn.id); + await assertNotMarked(conn, "singleton"); +}); + +test("flag on: a sibling that fails for another reason surfaces the original early-EOF 502", async () => { + const connA = await seedConnection("openai-sibfail-a", "sk-failover-sibfail-a"); + const connB = await seedConnection("openai-sibfail-b", "sk-failover-sibfail-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, (_auth, callIndex) => + callIndex < 2 ? pingOnlyStreamResponse() : unauthorizedResponse() + ); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + const first = dispatches[0] === authOf(connA) ? connA : connB; + const sibling = first === connA ? connB : connA; + assert.equal(dispatches.length, 3, `expected 1 + retry + sibling, got ${dispatches.length}`); + assert.equal(dispatches[2], authOf(sibling)); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF", `unexpected body: ${bodyText}`); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), first.id); + await assertNotMarked(first, "first"); +}); + +test("flag on: a forced connection never hops to a sibling", async () => { + const connA = await seedConnection("openai-forced-a", "sk-failover-forced-a"); + await seedConnection("openai-forced-b", "sk-failover-forced-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest({ "x-omniroute-connection": connA.id })); + const bodyText = await response.text(); + + // A forced pin skips the same-connection retry and must never rotate. + assert.equal(dispatches.length, 1, `expected 1 dispatch, got ${dispatches.length}`); + assert.equal(dispatches[0], authOf(connA)); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF"); +}); + +test("flag on: STREAM_READINESS_TIMEOUT stays terminal even with a sibling available", async () => { + const connA = await seedConnection("openai-timeout-a", "sk-failover-timeout-a"); + const connB = await seedConnection("openai-timeout-b", "sk-failover-timeout-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => stalledStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + // A slow-but-alive upstream is neither retried nor failed over. + assert.equal(dispatches.length, 1, `expected 1 dispatch, got ${dispatches.length}`); + assert.equal(response.status, 504, `expected 504, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_READINESS_TIMEOUT"); + await assertNotMarked(connA, "first"); + await assertNotMarked(connB, "sibling"); +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index d6486a0624..45748ab686 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 60; +const EXPECTED_FEATURE_FLAG_COUNT = 61; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -174,6 +174,22 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(midstream.warningLevel, "danger"); }); + it("defines early-EOF sibling failover as a runtime boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED" + ); + assert.ok(def, "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED should exist"); + assert.strictEqual(def.category, "runtime"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + assert.strictEqual(def.warningLevel, "info"); + assert.strictEqual( + def.descriptionI18nKey, + "featureFlagStreamEarlyEofSiblingFailoverEnabledDescription" + ); + }); + it("defines control-plane proxy direct fallback as a network boolean flag disabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK" diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 00a6e062cf..c837a17caa 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, 60); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 61); }); }); From df87e9363b6b08bd6b40ba9b774753b98d5a2cd4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 16:58:24 -0300 Subject: [PATCH 24/36] =?UTF-8?q?fix(auth):=20close=20the=20JWT=5FSECRET?= =?UTF-8?q?=20bootstrap=20chain=20=E2=80=94=20real-peer=20loopback,=20obsi?= =?UTF-8?q?dian=20always-protected,=20DATA=5FDIR=20vault=20refusal=20(#137?= =?UTF-8?q?91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-7pq4-8pvv-rx7r (critical). Every link of the reported chain held on the release tip: 1. First boot without JWT_SECRET generates one and writes it in cleartext to $DATA_DIR/server.env. 2. With no password configured, isAuthRequired() returned false for POST /api/settings/require-login unconditionally — before the loopback check — so any network peer could switch requireLogin off. 3. With requireLogin off, POST /api/settings/obsidian/webdav accepted an arbitrary vault root and echoed freshly minted Basic credentials. 4. The WebDAV file service is served by the custom Node layer before Next.js, outside the authz pipeline. 5. Pointing it at DATA_DIR reads server.env, and JWT_SECRET forges an `{"authenticated":true}` admin session. A second, worse problem surfaced while verifying: isLoopbackRequest() decided "loopback" from nextUrl.hostname / the Host header, which the client controls. `Host: localhost` from a remote address made the whole fresh-install bootstrap reachable, not just the write path. Three cuts, plus the root cause: - isLoopbackRequest() now reads the trusted peer: the token-stamped real TCP peer the custom server writes (peerStamp), then the pipeline's own locality verdict once a stamp token exists, then a real socket peer. The bootstrap write path honours the same constraint instead of returning false, and managementPolicy hands down the peerContext verdict explicitly, because at policy time the original request still carries client-supplied headers. - Host is consulted only when the process has no stamp token at all — no stamping server in front, which in practice means route handlers invoked directly by the unit-test harness. Every supported runtime (run-next dev and start, standalone-server-ws for Docker, the npm CLI and Electron) calls ensurePeerStampToken() at boot, so there a signal-less request fails closed. Without this fallback ~340 route tests that call handlers with `new Request("http://localhost/…")` turned into 401s. - /api/settings/obsidian joins ALWAYS_PROTECTED_API_PATHS: issuing and rotating reusable WebDAV credentials is credential export, the same rationale as the GHSA-62vw entry for the password reveal. - enableObsidianVaultSync() refuses a vault that is, sits inside, or contains DATA_DIR, comparing realpath-resolved paths so a symlink cannot dodge it. Tests are red-first: remote stamped peer → auth required on the bootstrap write; Host: localhost plus a forged locality header from a non-loopback stamped peer → 401 through the full pipeline; the local operator keeps the first-password flow; obsidian inventory and DATA_DIR overlap cases. --- ...-jwt-bootstrap-chain-real-peer-loopback.md | 1 + docs/openapi.yaml | 6 + docs/security/ROUTE_GUARD_TIERS.md | 21 ++ scripts/dev/run-protocol-clients-tests.mjs | 10 +- src/lib/obsidianSync.ts | 39 +++ src/server/authz/policies/management.ts | 12 +- src/server/authz/routeGuard.ts | 11 + src/shared/utils/apiAuth.ts | 179 ++++++++++--- tests/unit/api-auth.test.ts | 244 +++++++++++++++++- ...credential-export-always-protected.test.ts | 29 ++- tests/unit/authz/management-policy.test.ts | 95 ++++++- tests/unit/authz/pipeline.test.ts | 44 +++- tests/unit/obsidian-webdav-route.test.ts | 86 +++++- 13 files changed, 715 insertions(+), 62 deletions(-) create mode 100644 changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md diff --git a/changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md b/changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md new file mode 100644 index 0000000000..e8cd375ba7 --- /dev/null +++ b/changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md @@ -0,0 +1 @@ +- **fix(auth):** closed the JWT_SECRET bootstrap chain (GHSA-7pq4-8pvv-rx7r). The fresh-install bootstrap gate in `isAuthRequired()` now decides "loopback" from the trusted peer — the token-stamped real TCP peer the custom server writes, the pipeline's own locality verdict, or a real socket — and never from the client-controlled `Host` / `nextUrl.hostname` whenever a stamping server is in front (every supported runtime), so `Host: localhost` from a remote address no longer opens the window; the anonymous first-password write (`POST /api/settings/require-login`) is under the same loopback constraint instead of being open to every network peer, and `managementPolicy` hands its `peerContext` verdict down explicitly. `/api/settings/obsidian` (incl. `/webdav`, which mints reusable WebDAV Basic credentials for a caller-chosen root served before Next.js) joined `ALWAYS_PROTECTED_API_PATHS`, and `enableObsidianVaultSync()` refuses a vault that is, sits inside, or contains the data directory (realpath-resolved), so the WebDAV file service can no longer be pointed at `server.env` / `storage.sqlite` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ac93ffb20e..5e49a388dc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -11316,6 +11316,7 @@ paths: tags: - Settings summary: "DELETE settings › obsidian" + x-always-protected: true responses: "200": description: OK @@ -11323,6 +11324,7 @@ paths: tags: - Settings summary: "GET settings › obsidian" + x-always-protected: true responses: "200": description: OK @@ -11330,6 +11332,7 @@ paths: tags: - Settings summary: "POST settings › obsidian" + x-always-protected: true responses: "200": description: OK @@ -11338,6 +11341,7 @@ paths: tags: - Settings summary: "DELETE settings › obsidian › webdav" + x-always-protected: true responses: "200": description: OK @@ -11345,6 +11349,7 @@ paths: tags: - Settings summary: "GET settings › obsidian › webdav" + x-always-protected: true responses: "200": description: OK @@ -11352,6 +11357,7 @@ paths: tags: - Settings summary: "POST settings › obsidian › webdav" + x-always-protected: true responses: "200": description: OK diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index d3b69ff2d7..15b25fba0f 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -169,9 +169,30 @@ server process. | `/api/settings/export-json` | Exports the full settings blob (incl. secrets) | | `/api/settings/import-json` | Replaces the full settings blob | | `/api/providers/health-autopilot/actions` | Executes autopilot remediation actions | +| `/api/settings/obsidian` | Mints reusable WebDAV creds for any vault root | **Response on violation:** `401 Authentication required` +`/api/settings/obsidian` covers its `/webdav` child: `POST` points the WebDAV file service — +served by the custom Node layer before Next.js, outside this pipeline — at a caller-chosen root +and echoes freshly minted Basic credentials, `DELETE` rotates them, and the parent `POST` stores +the Obsidian REST API token. GHSA-62vw only masked the `GET` password reveal; the issuance was +still on the fail-open tier (GHSA-7pq4-8pvv-rx7r). `enableObsidianVaultSync()` additionally +refuses a vault that is, sits inside, or contains the data directory. + +### Fresh-install bootstrap is loopback-only — by real peer, not `Host` + +With no management password configured (and no `INITIAL_PASSWORD`), `isAuthRequired()` in +`src/shared/utils/apiAuth.ts` keeps the anonymous bootstrap open **only for loopback peers**. +Loopback is decided from the trusted peer signals, in order: the token-stamped real TCP peer +(`PEER_IP_HEADER` + `VIA_PROXY_HEADER`, what the policy sees), the pipeline's own +`AUTHZ_HEADER_PEER_LOCALITY` verdict (what route handlers see, trusted only while +`OMNIROUTE_PEER_STAMP_TOKEN` is set), or a real socket peer for direct callers. `Host` / +`nextUrl.hostname` are never consulted, and the first-password write +(`POST /api/settings/require-login`) is under the same constraint rather than open to every +network peer (GHSA-7pq4-8pvv-rx7r). `managementPolicy` passes its own `peerContext` verdict +down explicitly, so the ORIGINAL (pre-strip) request's headers never decide it. + ### Tier 3 — MANAGEMENT (default) All other management routes. Auth required unless `requireLogin=false` is diff --git a/scripts/dev/run-protocol-clients-tests.mjs b/scripts/dev/run-protocol-clients-tests.mjs index c32320c2f1..5300fd1ac3 100644 --- a/scripts/dev/run-protocol-clients-tests.mjs +++ b/scripts/dev/run-protocol-clients-tests.mjs @@ -57,11 +57,11 @@ async function main() { OMNIROUTE_BASE_URL: baseUrl, }), OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open", - // Pin the custom server's bind address to loopback (#11535): under the - // programmatic next() entry the middleware's nextUrl.hostname mirrors the - // configured HOST (default "0.0.0.0"), and apiAuth.isLoopbackRequest() reads - // nextUrl.hostname FIRST — an unpinned boot makes every request look remote, - // so the anonymous open-bootstrap allow never fires (401 green-shallow). + // Pin the custom server's bind address to loopback (#11535). The bootstrap + // loopback verdict (apiAuth.isLoopbackRequest) comes from the peer stamp the + // custom server writes from the real TCP socket (GHSA-7pq4-8pvv-rx7r), never + // from nextUrl.hostname / Host — the pin keeps the harness's own clients on a + // loopback socket so that stamp resolves to 127.0.0.1. HOST: process.env.HOST || "127.0.0.1", }; diff --git a/src/lib/obsidianSync.ts b/src/lib/obsidianSync.ts index febab545f8..4ae96a5500 100644 --- a/src/lib/obsidianSync.ts +++ b/src/lib/obsidianSync.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { resolveDataDir } from "./dataPaths"; import { getObsidianVaultPath, setObsidianVaultPath, @@ -35,6 +36,40 @@ export async function getObsidianSyncStatus(): Promise { return { vaultPath, webdavEnabled, webdavUsername, webdavPassword }; } +/** Canonical (symlink-resolved) form of a path; falls back to the lexical resolve. */ +function canonicalPath(target: string): string { + try { + return fs.realpathSync.native(target); + } catch { + return path.resolve(target); + } +} + +/** True when `child` is `parent` itself or lives anywhere below it. */ +function isSameOrInside(parent: string, child: string): boolean { + const rel = path.relative(parent, child); + if (rel === "") return true; + if (path.isAbsolute(rel)) return false; // different drive (win32) + return rel !== ".." && !rel.startsWith(`..${path.sep}`); +} + +/** + * GHSA-7pq4-8pvv-rx7r: the WebDAV file service (scripts/dev/webdav-handler.mjs) + * serves the vault root to anyone holding the Basic credentials, before Next.js + * and outside the authz pipeline. A vault that IS the data directory, sits + * inside it, or CONTAINS it turns that service into a reader for server.env + * (JWT_SECRET / STORAGE_ENCRYPTION_KEY / API_KEY_SECRET) and storage.sqlite. + * Both sides are realpath-resolved so a symlink cannot dodge the comparison. + */ +export function vaultPathOverlapsDataDir(resolvedVaultPath: string): boolean { + const vault = canonicalPath(resolvedVaultPath); + const dataDir = canonicalPath(resolveDataDir()); + return isSameOrInside(dataDir, vault) || isSameOrInside(vault, dataDir); +} + +export const VAULT_OVERLAPS_DATA_DIR_ERROR = + "Vault path must not be the OmniRoute data directory, a directory inside it, or a directory that contains it"; + export async function enableObsidianVaultSync( vaultPath: string ): Promise { @@ -49,6 +84,10 @@ export async function enableObsidianVaultSync( return { success: false, error: `Path is not a directory: ${resolvedPath}` }; } + if (vaultPathOverlapsDataDir(resolvedPath)) { + return { success: false, error: VAULT_OVERLAPS_DATA_DIR_ERROR }; + } + try { setObsidianVaultPath(resolvedPath); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 38613f2304..ee30ae4c78 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -251,7 +251,17 @@ export const managementPolicy: RoutePolicy = { } // Tier 2: always-protected routes skip the requireLogin=false bypass. - if (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) { + // + // The fresh-install bootstrap branch inside isAuthRequired() is loopback-only. + // Hand it the SAME trusted verdict the LOCAL_ONLY gate above used (token-stamped + // real TCP peer via peerContext) instead of letting it sniff ctx.request — the + // ORIGINAL request still carries every client-supplied header at this point, + // and the Host header was how a remote caller reached the anonymous + // POST /api/settings/require-login write (GHSA-7pq4-8pvv-rx7r). + if ( + !isAlwaysProtectedPath(path) && + !(await isAuthRequired(ctx.request, { loopback: isLoopbackRequest(ctx) })) + ) { return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" }); } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index a2b6a3571b..340f302910 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -177,6 +177,17 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray = [ // as the {claude,codex}-auth/apply-local pattern below; a plain path because // it carries no dynamic segment. "/api/providers/agy-auth/apply-local", + // Obsidian integration. POST /webdav points the WebDAV file service — served by + // the custom Node layer BEFORE Next.js, outside this pipeline — at a + // caller-chosen root and echoes freshly minted, reusable Basic credentials; + // DELETE /webdav rotates/clears them; the parent POST stores the Obsidian REST + // API token. GHSA-62vw only masked the GET password reveal, leaving credential + // *issuance* on the fail-open tier: with requireLogin flipped off during the + // bootstrap window, an anonymous caller stood up a file server over DATA_DIR + // and read JWT_SECRET out of server.env (GHSA-7pq4-8pvv-rx7r). Prefix covers + // the /webdav child. ALWAYS_PROTECTED rather than LOCAL_ONLY so an operator + // driving the dashboard through a tunnel keeps the feature. + "/api/settings/obsidian", ]; /** diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index 3c7afe96eb..ea5c8938df 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -9,6 +9,13 @@ import { cookies } from "next/headers"; import { getSettings } from "@/lib/db/settings"; +import { + AUTHZ_HEADER_PEER_LOCALITY, + PEER_IP_HEADER, + VIA_PROXY_HEADER, +} from "@/server/authz/headers"; +import { classifyStampedPeerLocality } from "@/server/authz/peerStamp"; +import { classifyHostLocality } from "@/server/authz/routeGuard"; import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes"; import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken"; import { extractApiKey } from "@/sse/services/auth"; @@ -21,9 +28,21 @@ type RequestLike = { method?: string; nextUrl?: { hostname?: string | null; pathname?: string | null } | null; url?: string; + /** Real socket peer, present only for direct Node / test callers (never in the proxy runtime). */ + ip?: string; + socket?: { remoteAddress?: string }; }; -const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]); +export interface AuthRequiredOptions { + /** + * Pre-resolved trusted peer verdict. The authz policy layer already resolves + * locality from the token-stamped real TCP peer (`peerContext.isLoopbackRequest`) + * and hands it down, so the bootstrap gate never re-derives it from headers on + * the ORIGINAL (pre-strip) request — where a client-supplied copy of the + * pipeline's own locality header could still be present. + */ + loopback?: boolean; +} function hasConfiguredPassword(settings: Record): boolean { return typeof settings.password === "string" && settings.password.length > 0; @@ -86,54 +105,116 @@ function getRequestMethod(request: RequestLike | Request | null | undefined): st return "GET"; } -function getRequestHostname(request: RequestLike | Request | null | undefined): string | null { - const nextHostname = - request && - typeof request === "object" && - "nextUrl" in request && - request.nextUrl && - typeof request.nextUrl.hostname === "string" - ? request.nextUrl.hostname - : null; - - if (nextHostname) return nextHostname; - - const rawUrl = - request && typeof request === "object" && "url" in request && typeof request.url === "string" - ? request.url - : ""; - - if (rawUrl) { - try { - return new URL(rawUrl, "http://localhost").hostname; - } catch { - // Fall through to Host header parsing. - } - } - +function getHeaderValue( + request: RequestLike | Request | null | undefined, + name: string +): string | null { const requestHeaders = request && typeof request === "object" && "headers" in request ? request.headers : undefined; - const host = requestHeaders?.get("host") || requestHeaders?.get("Host") || null; - if (!host) return null; - - try { - return new URL(`http://${host}`).hostname; - } catch { - return host.split(":")[0] || null; - } + return requestHeaders?.get?.(name) ?? null; } -export function isLoopbackRequest(request: RequestLike | Request | null | undefined): boolean { - const hostname = getRequestHostname(request); - if (!hostname) return false; +function getSocketPeerAddress(request: RequestLike | Request | null | undefined): string | null { + if (!request || typeof request !== "object") return null; + const candidate = request as RequestLike; + if (typeof candidate.ip === "string" && candidate.ip) return candidate.ip; + const remoteAddress = candidate.socket?.remoteAddress; + return typeof remoteAddress === "string" && remoteAddress ? remoteAddress : null; +} +/** + * Trusted peer locality for the fresh-install bootstrap gate. + * + * NEVER derived from `Host` / `nextUrl.hostname` / the request URL — all three + * are client-controlled, so a remote caller sending `Host: localhost` used to be + * treated as the local operator (GHSA-7pq4-8pvv-rx7r). The verdict comes from + * the same primitives the authz pipeline already trusts, in this order: + * + * 1. The token-stamped real TCP peer (`PEER_IP_HEADER` + `VIA_PROXY_HEADER`, + * written by the custom Node server from `req.socket.remoteAddress` after + * deleting any client-supplied value, validated against + * OMNIROUTE_PEER_STAMP_TOKEN). This is what the policy layer sees on the + * ORIGINAL request. A stamp present but failing validation → not loopback. + * A loopback socket flagged as a reverse-proxy hop → not loopback. + * 2. The pipeline's own locality verdict (`AUTHZ_HEADER_PEER_LOCALITY`), which + * route handlers see after `runAuthzPipeline` stripped every client-supplied + * copy and re-stamped it from (1). Trusted only while the per-process stamp + * token exists — i.e. a stamping server is actually in front of Next, in + * which case every request that reached the policy carried (1) and this + * branch can only be the post-strip route-handler view. + * 3. A real socket peer (`request.ip` / `request.socket.remoteAddress`) for + * direct Node / unit-test callers that never went through the pipeline. + * The proxy runtime exposes neither, so nothing here is client-reachable. + * + * Anything else → not loopback (fail closed), matching + * `src/server/authz/peerContext.ts::isLoopbackRequest`. + */ +export function isLoopbackRequest(request: RequestLike | Request | null | undefined): boolean { + if (!request || typeof request !== "object") return false; + + const stampToken = process.env.OMNIROUTE_PEER_STAMP_TOKEN; + + const stampedPeer = getHeaderValue(request, PEER_IP_HEADER); + if (stampedPeer !== null) { + return ( + classifyStampedPeerLocality( + stampedPeer, + getHeaderValue(request, VIA_PROXY_HEADER), + stampToken + ) === "loopback" + ); + } + + const pipelineVerdict = getHeaderValue(request, AUTHZ_HEADER_PEER_LOCALITY); + if (pipelineVerdict !== null && stampToken) { + return pipelineVerdict === "loopback"; + } + + const socketPeer = getSocketPeerAddress(request); + if (socketPeer) return classifyHostLocality(socketPeer) === "loopback"; + + // A stamping server is in front (every supported runtime — run-next dev/start and + // standalone-server-ws for Docker, the npm CLI and Electron — calls + // ensurePeerStampToken() at boot) but neither trusted signal is on this request: + // fail closed. The Host header is never consulted in that process. + if (stampToken) return false; + + // No stamping server in this process at all: route handlers invoked directly (the + // unit-test harness) or a raw `next` launch that also bypasses every LOCAL_ONLY + // gate in peerContext. There is no real peer to read, so keep the historical + // URL/Host verdict rather than turning every direct handler call into a remote one. + return isLegacyHostLoopback(request); +} + +function isLegacyHostLoopback(request: RequestLike | Request): boolean { + let hostname: string | null = null; + const candidate = request as RequestLike; + if (candidate.nextUrl && typeof candidate.nextUrl.hostname === "string") { + hostname = candidate.nextUrl.hostname; + } else if (typeof candidate.url === "string" && candidate.url) { + try { + hostname = new URL(candidate.url, "http://localhost").hostname; + } catch { + hostname = null; + } + } + if (!hostname) { + const host = getHeaderValue(request, "host"); + if (!host) return false; + try { + hostname = new URL(`http://${host}`).hostname; + } catch { + hostname = host.split(":")[0] || null; + } + } + if (!hostname) return false; const normalized = hostname .trim() .toLowerCase() .replace(/^\[(.*)\]$/, "$1"); - if (LOOPBACK_HOSTNAMES.has(normalized)) return true; - if (/^127(?:\.\d{1,3}){3}$/.test(normalized)) return true; - return false; + return ( + normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); } function getCookieValueFromHeader(headers: Headers | undefined, name: string): string | null { @@ -318,9 +399,13 @@ export function isPublicRoute(pathname: string, method = "GET"): boolean { * If requireLogin is explicitly false, auth is skipped. Fresh installs without * a password keep their unauthenticated bootstrap path only on loopback * requests; exposed network requests must configure INITIAL_PASSWORD or log in. + * + * "Loopback" is the trusted peer verdict (`isLoopbackRequest` above, or the + * policy-supplied `options.loopback`), never the Host header. */ export async function isAuthRequired( - request?: RequestLike | Request | null | undefined + request?: RequestLike | Request | null | undefined, + options?: AuthRequiredOptions ): Promise { try { const settings = await getSettings(); @@ -343,11 +428,19 @@ export async function isAuthRequired( return false; } + const loopback = options?.loopback ?? isLoopbackRequest(request); + + // The first-password write is the switch that disarms every other guard + // (requireLogin=false makes isAuthenticated() true everywhere), so it is + // the one bootstrap path that MUST honour the loopback constraint — it + // used to be an unconditional `return false`, open to any network peer + // during the window (GHSA-7pq4-8pvv-rx7r). It stays open for the local + // operator even after onboarding completed without a password. if (isRequireLoginBootstrapWritePath(pathname, method)) { - return false; + return !loopback; } - return settings.setupComplete === true || !isLoopbackRequest(request); + return settings.setupComplete === true || !loopback; } return true; diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 9784a688a1..017d9924ad 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -17,10 +17,16 @@ const apiAuth = await import("../../src/shared/utils/apiAuth.ts"); const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts"); const { getLegacyCliTokenSync, getMachineTokenSync } = await import("../../src/lib/machineToken.ts"); -const { CLI_TOKEN_HEADER } = await import("../../src/server/authz/headers.ts"); +const { AUTHZ_HEADER_PEER_LOCALITY, CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } = + await import("../../src/server/authz/headers.ts"); const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const ORIGINAL_PEER_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN; + +// The per-process secret the custom Node server uses to stamp the real TCP peer +// (scripts/dev/peer-stamp.mjs). Tests mint the same `|` shape. +const TEST_PEER_STAMP_TOKEN = "api-auth-test-peer-stamp-token"; async function resetStorage() { core.resetDbInstance(); @@ -29,6 +35,25 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); delete process.env.JWT_SECRET; delete process.env.INITIAL_PASSWORD; + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; +} + +/** + * A request as the authz policy sees it: the custom server already stamped the + * real TCP peer into PEER_IP_HEADER (token-validated), so the verdict cannot be + * influenced by the URL / Host header the client chose. + */ +function stampedPeerRequest(url: string, peerIp: string, init: RequestInit = {}): Request { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + const headers = new Headers(init.headers); + headers.set(PEER_IP_HEADER, `${TEST_PEER_STAMP_TOKEN}|${peerIp}`); + headers.set(VIA_PROXY_HEADER, `${TEST_PEER_STAMP_TOKEN}|0`); + return new Request(url, { ...init, headers }); +} + +/** A direct Node / non-pipeline caller carrying a real socket peer. */ +function socketPeerRequest(url: string, peerIp: string, init: RequestInit = {}): Request { + return Object.assign(new Request(url, init), { ip: peerIp }) as Request; } function makeCookieRequest(token: string) { @@ -62,6 +87,12 @@ test.after(() => { } else { process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; } + + if (ORIGINAL_PEER_STAMP_TOKEN === undefined) { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } else { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_PEER_STAMP_TOKEN; + } }); test("isPublicRoute recognizes allowed API prefixes", () => { @@ -311,12 +342,212 @@ test("isAuthRequired is disabled while no password exists", async () => { test("isAuthRequired keeps fresh bootstrap open only on loopback", async () => { await localDb.updateSettings({ requireLogin: true, password: "" }); - assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false); - assert.equal(await apiAuth.isAuthRequired(new Request("http://127.0.0.1/api/providers")), false); + // Loopback is decided from the trusted peer (token-stamped real TCP peer or a + // real socket), never from the URL / Host header (GHSA-7pq4-8pvv-rx7r). + assert.equal( + await apiAuth.isAuthRequired(stampedPeerRequest("http://localhost/api/providers", "127.0.0.1")), + false + ); + assert.equal( + await apiAuth.isAuthRequired(stampedPeerRequest("http://127.0.0.1/api/providers", "::1")), + false + ); + assert.equal( + await apiAuth.isAuthRequired(socketPeerRequest("http://localhost/api/providers", "127.0.0.1")), + false + ); assert.equal( await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")), true ); + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("https://example.com/api/providers", "203.0.113.9") + ), + true + ); +}); + +// ── GHSA-7pq4-8pvv-rx7r — the bootstrap gate must not trust Host / nextUrl ───── + +test("isLoopbackRequest ignores a spoofed Host header — a non-loopback stamped peer is never loopback (GHSA-7pq4-8pvv-rx7r)", async () => { + // Remote attacker sending `Host: localhost` (the URL's hostname is exactly what + // nextUrl.hostname / the Host header carry). The custom server stamped the real + // peer as 203.0.113.9 → NOT loopback, whatever the client put in Host. + const spoofed = stampedPeerRequest("http://localhost/api/providers", "203.0.113.9", { + headers: { host: "localhost" }, + }); + assert.equal(apiAuth.isLoopbackRequest(spoofed), false); + + // A Host-only "localhost" with no trusted peer signal at all is not loopback either. + assert.equal( + apiAuth.isLoopbackRequest(new Request("http://localhost/api/providers")), + false, + "Host / nextUrl.hostname alone must never make a request loopback" + ); + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://127.0.0.1/api/providers", { headers: { host: "127.0.0.1" } }) + ), + false + ); + + // The forged stamp shape (`|127.0.0.1`) fails closed. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { + headers: { [PEER_IP_HEADER]: "not-the-process-token|127.0.0.1" }, + }) + ), + false + ); + + // The genuine stamp for a loopback peer IS loopback — but not when the custom + // server also flagged that the request arrived through a reverse-proxy hop. + assert.equal( + apiAuth.isLoopbackRequest(stampedPeerRequest("https://example.com/api/providers", "127.0.0.1")), + true + ); + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { + headers: { + [PEER_IP_HEADER]: `${TEST_PEER_STAMP_TOKEN}|127.0.0.1`, + [VIA_PROXY_HEADER]: `${TEST_PEER_STAMP_TOKEN}|1`, + }, + }) + ), + false + ); +}); + +test("isLoopbackRequest consults Host only when no stamping server exists in the process (GHSA-7pq4-8pvv-rx7r)", async () => { + // Every supported runtime calls ensurePeerStampToken() at boot, so once a token + // exists a signal-less request is never loopback, whatever Host says. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { headers: { host: "localhost" } }) + ), + false, + "with a stamping server in front, Host must never make a request loopback" + ); + + // No token at all = no stamping server = direct handler invocation (the unit-test + // harness). There is no real peer to read, so the historical URL verdict applies — + // and it still rejects a non-loopback hostname. + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + assert.equal(apiAuth.isLoopbackRequest(new Request("http://localhost/api/providers")), true); + assert.equal(apiAuth.isLoopbackRequest(new Request("https://example.com/api/providers")), false); +}); + +test("isLoopbackRequest trusts the pipeline locality verdict only when a stamping server is in front (GHSA-7pq4-8pvv-rx7r)", async () => { + // Route handlers see AUTHZ_HEADER_PEER_LOCALITY, re-stamped by the pipeline + // after every client-supplied copy was stripped — trustworthy only when the + // per-process stamp token exists (i.e. the custom server is actually stamping). + const verdict = new Request("https://example.com/api/providers", { + headers: { [AUTHZ_HEADER_PEER_LOCALITY]: "loopback" }, + }); + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + assert.equal(apiAuth.isLoopbackRequest(verdict), false); + + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + assert.equal(apiAuth.isLoopbackRequest(verdict), true); + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { + headers: { [AUTHZ_HEADER_PEER_LOCALITY]: "remote" }, + }) + ), + false + ); + + // A forged locality header never outranks the real stamped peer. + assert.equal( + apiAuth.isLoopbackRequest( + stampedPeerRequest("http://localhost/api/providers", "203.0.113.9", { + headers: { [AUTHZ_HEADER_PEER_LOCALITY]: "loopback" }, + }) + ), + false + ); +}); + +test("isAuthRequired gates the bootstrap require-login write on the trusted peer (GHSA-7pq4-8pvv-rx7r)", async () => { + await localDb.updateSettings({ requireLogin: true, password: "" }); + + // The write that disarms every other guard (requireLogin=false) used to be an + // unconditional `return false` — open to any network peer in the window. + assert.equal( + await apiAuth.isAuthRequired( + new Request("https://example.com/api/settings/require-login", { method: "POST" }) + ), + true, + "remote POST /api/settings/require-login must require auth in the bootstrap window" + ); + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("http://localhost/api/settings/require-login", "203.0.113.9", { + method: "POST", + headers: { host: "localhost" }, + }) + ), + true, + "Host: localhost from a non-loopback stamped peer must not reopen the write path" + ); + assert.equal( + await apiAuth.isAuthenticated( + new Request("https://example.com/api/settings/require-login", { method: "POST" }) + ), + false + ); + + // The genuine local operator keeps the first-password flow — including after + // onboarding completed without a password (setupComplete: true). + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("http://localhost/api/settings/require-login", "127.0.0.1", { + method: "POST", + }) + ), + false + ); + await localDb.updateSettings({ requireLogin: true, password: "", setupComplete: true }); + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("http://localhost/api/settings/require-login", "127.0.0.1", { + method: "POST", + }) + ), + false + ); + assert.equal( + await apiAuth.isAuthRequired( + new Request("https://example.com/api/settings/require-login", { method: "POST" }) + ), + true + ); +}); + +test("isAuthRequired honours an explicit trusted loopback verdict from the policy layer", async () => { + await localDb.updateSettings({ requireLogin: true, password: "" }); + + // The authz policy resolves locality itself (peerContext) and hands the + // verdict down, so the bootstrap gate never re-reads the ORIGINAL request's + // client-controlled headers. + const forged = new Request("http://localhost/api/settings/require-login", { + method: "POST", + headers: { host: "localhost", [AUTHZ_HEADER_PEER_LOCALITY]: "loopback" }, + }); + assert.equal(await apiAuth.isAuthRequired(forged, { loopback: false }), true); + assert.equal(await apiAuth.isAuthRequired(forged, { loopback: true }), false); + assert.equal( + await apiAuth.isAuthRequired(new Request("https://example.com/api/providers"), { + loopback: true, + }), + false + ); }); test("isAuthenticated rejects remote management bootstrap without a configured password", async () => { @@ -368,8 +599,11 @@ test("isAuthRequired treats partial OIDC config as not configured (bootstrap beh // missing clientId + clientSecret }); - // On loopback without full config → bootstrap allowed - assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false); + // On loopback (trusted stamped peer) without full config → bootstrap allowed + assert.equal( + await apiAuth.isAuthRequired(stampedPeerRequest("http://localhost/api/providers", "127.0.0.1")), + false + ); // Remote still requires auth assert.equal( await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")), diff --git a/tests/unit/authz/credential-export-always-protected.test.ts b/tests/unit/authz/credential-export-always-protected.test.ts index c297bad003..5ab4a802b9 100644 --- a/tests/unit/authz/credential-export-always-protected.test.ts +++ b/tests/unit/authz/credential-export-always-protected.test.ts @@ -58,6 +58,20 @@ const HARD_GATED_INVENTORY: ReadonlyArray<{ path: string; why: string }> = [ path: "/api/providers/agy-auth/apply-local", why: "writes into ~/.gemini/antigravity-cli/antigravity-oauth-token", }, + // ── Reported in GHSA-7pq4-8pvv-rx7r (JWT_SECRET bootstrap chain) ───────── + // POST points the Obsidian WebDAV file service — served by the custom Node + // layer BEFORE Next.js, so the authz pipeline never runs for it — at an + // attacker-chosen root and echoes freshly minted Basic credentials; DELETE + // rotates/clears them. GHSA-62vw only masked the GET reveal; the credential + // *issuance* was still on the fail-open tier. + { + path: "/api/settings/obsidian/webdav", + why: "POST returns reusable WebDAV Basic credentials for a caller-chosen root; DELETE rotates them (GHSA-7pq4-8pvv-rx7r)", + }, + { + path: "/api/settings/obsidian", + why: "POST stores the Obsidian Local REST API token; same credential surface as its /webdav child (GHSA-7pq4-8pvv-rx7r)", + }, // ── Already fixed; pinned so a refactor cannot silently drop them ──────── { path: "/api/db-backups/export", why: "GHSA-mghq-58h3-qcqj" }, { path: "/api/db-backups/exportAll", why: "GHSA-mghq-58h3-qcqj" }, @@ -126,7 +140,20 @@ test("a connection id cannot escape the pattern with a slash", () => { }); test("the plain-path allowlist keeps its existing entries", () => { - for (const p of ["/api/shutdown", "/api/settings/database", "/api/db-backups"]) { + for (const p of [ + "/api/shutdown", + "/api/settings/database", + "/api/db-backups", + "/api/settings/obsidian", + ]) { assert.ok(ALWAYS_PROTECTED_API_PATHS.includes(p), p); } }); + +test("the obsidian entry does not over-protect its /api/settings neighbours", () => { + // `/api/settings/obsidian` is a plain prefix; the sibling settings routes must + // stay on the MANAGEMENT tier for keyless local-first installs. + for (const path of ["/api/settings", "/api/settings/notion", "/api/settings/require-login"]) { + assert.equal(isAlwaysProtectedPath(path), false, path); + } +}); diff --git a/tests/unit/authz/management-policy.test.ts b/tests/unit/authz/management-policy.test.ts index 6e8feda318..aedd82d81d 100644 --- a/tests/unit/authz/management-policy.test.ts +++ b/tests/unit/authz/management-policy.test.ts @@ -112,7 +112,12 @@ function remoteCtx(headers: Headers, method = "GET", path = "/api/keys") { test("managementPolicy: allows when auth not required (no password set)", async () => { await settingsDb.updateSettings({ requireLogin: true, password: null }); const policy = await loadPolicy(); - const out = await policy.evaluate(ctx(new Headers())); + // Fresh-bootstrap anonymous allow is loopback-only, and loopback is decided + // from the real peer (socket.remoteAddress / stamped peer), never from the + // `http://localhost` URL the ctx() helper carries (GHSA-7pq4-8pvv-rx7r). + const out = await policy.evaluate( + ctx(new Headers(), "GET", "/api/keys", { socket: { remoteAddress: "127.0.0.1" } }) + ); assert.equal(out.allow, true); if (out.allow) { assert.equal(out.subject.kind, "anonymous"); @@ -133,6 +138,94 @@ test("managementPolicy: rejects remote fresh bootstrap without a password", asyn } }); +// ─── GHSA-7pq4-8pvv-rx7r — bootstrap first-password write is loopback-only ──── +// +// `POST /api/settings/require-login` in the bootstrap window used to be an +// unconditional anonymous allow (apiAuth.isAuthRequired returned false before +// the loopback check), and the loopback check itself read the client-controlled +// Host header. A remote caller could flip requireLogin=false, then read +// JWT_SECRET through the Obsidian WebDAV file service and forge a durable admin +// session. The policy must decide from the token-stamped real peer. + +const BOOTSTRAP_WRITE_PATH = "/api/settings/require-login"; +const POLICY_STAMP_TOKEN = "mgmt-policy-test-peer-stamp-token"; + +function stampedHeaders(peerIp: string, extra: Record = {}): Headers { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = POLICY_STAMP_TOKEN; + return new Headers({ + ...extra, + "x-omniroute-peer-ip": `${POLICY_STAMP_TOKEN}|${peerIp}`, + "x-omniroute-via-proxy": `${POLICY_STAMP_TOKEN}|0`, + }); +} + +test("managementPolicy: rejects an anonymous remote POST /api/settings/require-login in the bootstrap window (GHSA-7pq4-8pvv-rx7r)", async () => { + await settingsDb.updateSettings({ requireLogin: true, password: null }); + const policy = await loadPolicy(); + try { + // Plain remote peer, no stamp at all → fail closed. + const unstamped = await policy.evaluate(remoteCtx(new Headers(), "POST", BOOTSTRAP_WRITE_PATH)); + assert.equal(unstamped.allow, false); + if (!unstamped.allow) { + assert.equal(unstamped.status, 401); + assert.equal(unstamped.code, "AUTH_001"); + } + + // Host-spoof: the URL / Host header say localhost, the stamped real peer is + // a public address, and the client even forged the pipeline's locality + // verdict header. None of that is loopback. + const spoofed = await policy.evaluate( + ctx( + stampedHeaders("203.0.113.9", { + host: "localhost:20128", + "x-omniroute-peer-locality": "loopback", + }), + "POST", + BOOTSTRAP_WRITE_PATH + ) + ); + assert.equal(spoofed.allow, false); + if (!spoofed.allow) { + assert.equal(spoofed.status, 401); + assert.equal(spoofed.code, "AUTH_001"); + } + } finally { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } +}); + +test("managementPolicy: keeps the bootstrap first-password write open for the stamped loopback peer (GHSA-7pq4-8pvv-rx7r)", async () => { + await settingsDb.updateSettings({ requireLogin: true, password: null, setupComplete: true }); + const policy = await loadPolicy(); + try { + const local = await policy.evaluate( + ctx(stampedHeaders("127.0.0.1"), "POST", BOOTSTRAP_WRITE_PATH) + ); + assert.equal(local.allow, true); + if (local.allow) { + assert.equal(local.subject.kind, "anonymous"); + assert.equal(local.subject.label, "auth-disabled"); + } + + // A loopback socket that is really a reverse-proxy hop (via-proxy marker + // set by the custom server) is NOT the local operator. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = POLICY_STAMP_TOKEN; + const viaProxy = await policy.evaluate( + ctx( + new Headers({ + "x-omniroute-peer-ip": `${POLICY_STAMP_TOKEN}|127.0.0.1`, + "x-omniroute-via-proxy": `${POLICY_STAMP_TOKEN}|1`, + }), + "POST", + BOOTSTRAP_WRITE_PATH + ) + ); + assert.equal(viaProxy.allow, false); + } finally { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } +}); + test("managementPolicy: rejects 401 when auth required and no credentials", async () => { process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; process.env.INITIAL_PASSWORD = "initial-pass"; diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index d89b152219..88cbc50680 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -200,7 +200,7 @@ test("runAuthzPipeline allows onboarding when login is required but no password assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC"); }); -test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => { +test("runAuthzPipeline allows first password writes when login is required but no password exists — from the stamped loopback peer only (GHSA-7pq4-8pvv-rx7r)", async () => { delete process.env.INITIAL_PASSWORD; await settingsDb.updateSettings({ requireLogin: true, @@ -208,13 +208,47 @@ test("runAuthzPipeline allows first password writes when login is required but n password: "", }); - const response = await pipeline.runAuthzPipeline( + // The local operator (real TCP peer 127.0.0.1, stamped by the custom server) + // keeps the first-password flow, whatever hostname they typed. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = "pipeline-test-peer-stamp-token"; + const local = await pipeline.runAuthzPipeline( + request("https://example.com/api/settings/require-login", { + method: "POST", + headers: { + "x-omniroute-peer-ip": "pipeline-test-peer-stamp-token|127.0.0.1", + "x-omniroute-via-proxy": "pipeline-test-peer-stamp-token|0", + }, + }), + { enforce: true } + ); + assert.equal(local.status, 200); + assert.equal(local.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + + // A remote peer — even one spelling the URL as localhost and forging the + // pipeline's own locality verdict header — must not reach the anonymous write + // that flips requireLogin=false (the first link of the JWT_SECRET chain). + const spoofed = await pipeline.runAuthzPipeline( + request("http://localhost/api/settings/require-login", { + method: "POST", + headers: { + host: "localhost", + "x-omniroute-peer-locality": "loopback", + "x-omniroute-peer-ip": "pipeline-test-peer-stamp-token|203.0.113.9", + "x-omniroute-via-proxy": "pipeline-test-peer-stamp-token|0", + }, + }), + { enforce: true } + ); + assert.equal(spoofed.status, 401); + assert.equal((await spoofed.json()).error.code, "AUTH_001"); + + // No stamp at all (nothing trustworthy about the peer) → fail closed. + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + const unstamped = await pipeline.runAuthzPipeline( request("https://example.com/api/settings/require-login", { method: "POST" }), { enforce: true } ); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + assert.equal(unstamped.status, 401); }); test("runAuthzPipeline keeps management API rejections as JSON", async () => { diff --git a/tests/unit/obsidian-webdav-route.test.ts b/tests/unit/obsidian-webdav-route.test.ts index a92d06d332..fd03d79da6 100644 --- a/tests/unit/obsidian-webdav-route.test.ts +++ b/tests/unit/obsidian-webdav-route.test.ts @@ -42,7 +42,26 @@ async function resetStorage() { } function makeRequest(url: string, options?: RequestInit): NextRequest { - return new Request(url, options) as unknown as NextRequest; + // These tests exercise the handler in the fresh-install open mode (no password + // configured, requireLogin default). That mode is loopback-only, and loopback is + // decided from the real peer — never from the `http://localhost` URL + // (GHSA-7pq4-8pvv-rx7r) — so give the direct-call Request a loopback socket peer. + return Object.assign(new Request(url, options), { ip: "127.0.0.1" }) as unknown as NextRequest; +} + +function postVault(vaultPath: string): Promise { + return route.POST( + makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath }), + }) + ); +} + +async function errorMessageOf(res: Response): Promise { + const body = (await res.json()) as Record; + return (body.error as Record | undefined)?.message as string | undefined; } test.beforeEach(async () => { @@ -191,6 +210,71 @@ test("POST with a non-existent path → 400, body does NOT contain a stack trace ); }); +// ── GHSA-7pq4-8pvv-rx7r — the vault root must never expose the data directory ── +// +// The WebDAV file service (scripts/dev/webdav-handler.mjs) serves the vault root +// to anyone holding the Basic credentials, before Next.js and outside the authz +// pipeline. Pointing it at DATA_DIR (or a parent of it) hands out server.env — +// JWT_SECRET / STORAGE_ENCRYPTION_KEY / API_KEY_SECRET — and storage.sqlite. + +test("POST rejects a vaultPath that IS the data directory → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const res = await postVault(TEST_DATA_DIR); + assert.equal(res.status, 400); + const msg = await errorMessageOf(res); + assert.ok(msg && /data directory/i.test(msg), `expected a data-directory refusal, got: ${msg}`); + assert.ok(!msg.includes("at /"), "error must not carry a stack trace"); + assert.ok(!msg.includes(TEST_DATA_DIR), "error must not echo the data directory location"); + assert.equal(obsidianDb.getWebdavEnabled(), false, "WebDAV must stay disabled"); + assert.equal(obsidianDb.getObsidianVaultPath(), null, "vault path must not be stored"); +}); + +test("POST rejects a vaultPath that CONTAINS the data directory (parent dir) → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const res = await postVault(path.dirname(TEST_DATA_DIR)); + assert.equal(res.status, 400); + const msg = await errorMessageOf(res); + assert.ok(msg && /data directory/i.test(msg), `expected a data-directory refusal, got: ${msg}`); + assert.equal(obsidianDb.getWebdavEnabled(), false); +}); + +test("POST rejects a vaultPath INSIDE the data directory → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const inside = path.join(TEST_DATA_DIR, "db_backups"); + fs.mkdirSync(inside, { recursive: true }); + const res = await postVault(inside); + assert.equal(res.status, 400); + assert.equal(obsidianDb.getWebdavEnabled(), false); +}); + +test("POST rejects a symlink that resolves to the data directory → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const linkParent = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-link-")); + const link = path.join(linkParent, "vault"); + try { + fs.symlinkSync(TEST_DATA_DIR, link, "dir"); + } catch { + fs.rmSync(linkParent, { recursive: true, force: true }); + return; // platform without symlink permission — nothing to assert + } + try { + const res = await postVault(link); + assert.equal(res.status, 400); + assert.equal(obsidianDb.getWebdavEnabled(), false); + } finally { + fs.rmSync(linkParent, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("POST with a directory unrelated to the data directory still succeeds (sibling in tmp)", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-ok-")); + try { + const res = await postVault(vaultDir); + assert.equal(res.status, 200); + const body = (await res.json()) as Record; + assert.equal(body.vaultPath, path.resolve(vaultDir)); + assert.equal(obsidianDb.getWebdavEnabled(), true); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + test("POST with invalid body (missing vaultPath) → 400", async () => { const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { method: "POST", From 29d66cbf8c1b08ed6851ba656ee332e55c0e733c Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:09:02 +0200 Subject: [PATCH 25/36] feat(proxies): stop re-serving a proxy that just failed (#13578) Behind the new `PROXY_SKIP_RECENTLY_FAILED` flag (default off), pool rotation and the opencode account rotation remember a proxy that just failed (refused probe or 429) and skip it for a doubling cooldown instead of re-serving it immediately. Maintainer rework before merge (kept the idea, no default behavior change): - The original was on by default and re-queried the DB on every request while a member was set aside; selection now caches a refusal sequence number and re-runs the cascade once per set-aside event. - `src/lib/db` no longer imports the heavy dispatcher for key normalization (a parity test guarantees the same key as `proxyConfigToUrl()`); `.env.example` and `ENVIRONMENT.md` document the default as false. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .env.example | 6 + .../13578-proxy-skip-recently-failed.md | 1 + docs/reference/ENVIRONMENT.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/executors/opencode.ts | 28 ++- open-sse/utils/proxyRefusalMemory.ts | 198 +++++++++++++++ src/i18n/messages/am.json | 3 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/az.json | 1 + src/i18n/messages/bg.json | 1 + src/i18n/messages/bn.json | 1 + src/i18n/messages/cs.json | 1 + src/i18n/messages/da.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/el.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/et.json | 1 + src/i18n/messages/fa.json | 1 + src/i18n/messages/fi.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ga.json | 1 + src/i18n/messages/gu.json | 1 + src/i18n/messages/ha.json | 3 +- src/i18n/messages/he.json | 1 + src/i18n/messages/hi.json | 1 + src/i18n/messages/hr.json | 1 + src/i18n/messages/hu.json | 1 + src/i18n/messages/hy.json | 3 +- src/i18n/messages/id.json | 1 + src/i18n/messages/ig.json | 3 +- src/i18n/messages/it.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ka.json | 3 +- src/i18n/messages/km.json | 1 + src/i18n/messages/kn.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/lt.json | 1 + src/i18n/messages/lv.json | 1 + src/i18n/messages/ml.json | 1 + src/i18n/messages/mr.json | 1 + src/i18n/messages/ms.json | 1 + src/i18n/messages/mt.json | 1 + src/i18n/messages/my.json | 1 + src/i18n/messages/ne.json | 1 + src/i18n/messages/nl.json | 1 + src/i18n/messages/no.json | 1 + src/i18n/messages/or.json | 1 + src/i18n/messages/pa.json | 1 + src/i18n/messages/phi.json | 1 + src/i18n/messages/pl.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/ro.json | 1 + src/i18n/messages/ru.json | 1 + src/i18n/messages/si.json | 1 + src/i18n/messages/sk.json | 1 + src/i18n/messages/sl.json | 1 + src/i18n/messages/sr.json | 1 + src/i18n/messages/sv.json | 1 + src/i18n/messages/sw.json | 1 + src/i18n/messages/ta.json | 1 + src/i18n/messages/te.json | 1 + src/i18n/messages/th.json | 1 + src/i18n/messages/tr.json | 1 + src/i18n/messages/uk-UA.json | 1 + src/i18n/messages/ur.json | 1 + src/i18n/messages/uz.json | 3 +- src/i18n/messages/vi.json | 1 + src/i18n/messages/yo.json | 3 +- src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/db/proxies/rotation.ts | 49 +++- src/lib/db/settings.ts | 78 +++--- src/lib/proxyHealth.ts | 22 ++ .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 16 ++ tests/unit/feature-flags-settings.test.ts | 13 +- .../opencode-proxy-refusal-memory.test.ts | 196 +++++++++++++++ .../unit/proxy-health-refusal-memory.test.ts | 64 +++++ .../proxy-pool-skips-refused-member.test.ts | 233 ++++++++++++++++++ tests/unit/proxy-refusal-memory.test.ts | 199 +++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 83 files changed, 1158 insertions(+), 47 deletions(-) create mode 100644 changelog.d/features/13578-proxy-skip-recently-failed.md create mode 100644 open-sse/utils/proxyRefusalMemory.ts create mode 100644 tests/unit/opencode-proxy-refusal-memory.test.ts create mode 100644 tests/unit/proxy-health-refusal-memory.test.ts create mode 100644 tests/unit/proxy-pool-skips-refused-member.test.ts create mode 100644 tests/unit/proxy-refusal-memory.test.ts diff --git a/.env.example b/.env.example index aee4c12a0d..4bf685dc94 100644 --- a/.env.example +++ b/.env.example @@ -737,6 +737,12 @@ NEXT_PUBLIC_CLOUD_URL= ENABLE_SOCKS5_PROXY=true NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true +# Opt-in feature flag (default off; a dashboard DB override wins over this value): proxy pools +# and per-account rotation stop re-serving a member that just failed (TCP probe refused, or a +# 429 received through it) for a period that doubles on each repeat, up to a cap. No proxy +# status is written. "true" (or 1, yes) enables it; unset keeps plain selection. +# PROXY_SKIP_RECENTLY_FAILED=false + # Standard proxy variables (lowercase variants also supported). # HTTP_PROXY=http://127.0.0.1:7890 # HTTPS_PROXY=http://127.0.0.1:7890 diff --git a/changelog.d/features/13578-proxy-skip-recently-failed.md b/changelog.d/features/13578-proxy-skip-recently-failed.md new file mode 100644 index 0000000000..96e7c20147 --- /dev/null +++ b/changelog.d/features/13578-proxy-skip-recently-failed.md @@ -0,0 +1 @@ +- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 5ac099334e..f29f0ad0aa 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -363,6 +363,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | ---------------------------------------- | --------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. Opt-out with `false`. | | `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | +| `PROXY_SKIP_RECENTLY_FAILED` | `false` | `src/shared/utils/featureFlags.ts` | Opt-in feature flag (see [FEATURE_FLAGS.md](./FEATURE_FLAGS.md); a dashboard DB override wins). Proxy pools and per-account rotation stop re-serving a member that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat, up to a cap. `true` (or `1`, `yes`) enables it. | | `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | | `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index bac50a5992..7803e19937 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 -61 flags across 6 categories. **Default** is the definition default — the value +62 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) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (9) +### Network (10) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -73,6 +73,7 @@ used when neither a DB override nor an environment variable is present. | `PROXY_AUTO_SELECT_ENABLED` | boolean | `false` | | When no proxy is assigned to a connection, auto-select the first working proxy from the registry. Off by default (otherwise any registry proxy becomes a global fallback — #3332). | | `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | boolean | `false` | | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Off by default because this can change egress IP. | | `NETWORK_ROTATION_SHARED_EGRESS_GUARD` | boolean | `true` | | On a network exception (timeout, connection refused/reset) for a multi-account rotation executor, when the failing account has no dedicated proxy, apply a short cooldown and skip other proxy-less accounts for the rest of the request instead of retrying each one. On by default (safe: no egress IP change, only reduces latency/cooldown risk on shared-egress accounts). Disable to restore immediate propagation on the first proxy-less throw. | +| `PROXY_SKIP_RECENTLY_FAILED` | boolean | `false` | | Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -201,7 +202,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 61 flags + // ... all 62 flags ], "summary": { "total": 56, diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index ce8ea29b60..a2b3cb5e92 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -30,7 +30,17 @@ import { } from "./accountRotation.ts"; import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts"; import { isRetriableUpstreamFailure } from "./opencodeTransientFailure.ts"; -import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags"; +import { + hasProxyRefusals, + isProxyAvoided, + noteProxyRefusal, + noteProxyServed, + proxyEgressKey, +} from "../utils/proxyRefusalMemory.ts"; +import { + isNetworkRotationSharedEgressGuardEnabled, + isProxySkipRecentlyFailedEnabled, +} from "@/shared/utils/featureFlags"; /** * The main OpenCode Zen host, shared by the `opencode` and `opencode-zen` @@ -352,6 +362,9 @@ export class OpencodeExecutor extends BaseExecutor { private markSuccess(account: OpencodeAccountState): void { markAccountSuccess(account); + // A response came back through this proxy: it is usable again for every refusal kind. + // Nothing is held unless PROXY_SKIP_RECENTLY_FAILED was on, so this costs no flag read. + if (hasProxyRefusals()) noteProxyServed(proxyEgressKey(account.proxy)); } /** @@ -586,6 +599,9 @@ export class OpencodeExecutor extends BaseExecutor { // model (geo-blocked, or transient 5xx). Request-local only — nothing // persists past execute(). const geoTriedProxyKeys = new Set(); + // Opt-in (PROXY_SKIP_RECENTLY_FAILED, default off): members the provider just refused + // (received refusal or refused TCP probe) are skipped. Off = plain rotation. + const skipRecentlyFailed = isProxySkipRecentlyFailedEnabled(); let directTried = false; for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { @@ -594,6 +610,7 @@ export class OpencodeExecutor extends BaseExecutor { // Without any geo evidence this pass, every cooldown-ready account // stays eligible (preserves the plain round-robin first pick). if (a.proxy === null) return !directTried || geoTriedProxyKeys.size === 0; + if (skipRecentlyFailed && isProxyAvoided(proxyEgressKey(a.proxy))) return false; const k = proxyKeyOf(a.proxy); return k !== null && !geoTriedProxyKeys.has(k); }; @@ -699,9 +716,16 @@ export class OpencodeExecutor extends BaseExecutor { const status = result.response.status; if (status === 429) { this.markCooldown(account); + // The provider refused through this member: set it aside beyond the account + // cooldown. A direct account has a null key and is never set aside. + const setAsideMs = skipRecentlyFailed + ? noteProxyRefusal(proxyEgressKey(account.proxy), "ip_quota_429") + : null; log?.warn?.( "OPENCODE", - `${cid}Rate limited (429) on account ${masked}, rotating to next…` + `${cid}Rate limited (429) on account ${masked}` + + (setAsideMs ? `, member set aside for ${Math.round(setAsideMs / 1000)}s` : "") + + ", rotating to next…" ); continue; } diff --git a/open-sse/utils/proxyRefusalMemory.ts b/open-sse/utils/proxyRefusalMemory.ts new file mode 100644 index 0000000000..7479b720d9 --- /dev/null +++ b/open-sse/utils/proxyRefusalMemory.ts @@ -0,0 +1,198 @@ +/** + * Short-lived, per-process memory of proxies that just failed, shared by both places that + * pick a proxy: registry pools (#6365) and the per-account rotation of noauth executors. + * A failed proxy is set aside for a period that doubles on each repeat, up to a cap, then + * comes back. Nothing is persisted and no proxy status is written: only the order in which + * candidates are tried changes. Keys are entry points (scheme, username, host, port), + * never passwords. + * + * This module is a pure store: it never reads the PROXY_SKIP_RECENTLY_FAILED feature flag + * (callers gate writes and decisions on it) and it stays free of the proxy dispatcher, so + * the DB layer can consult it without loading undici or the SOCKS connector. + */ +import { COOLDOWN_MS } from "../config/errorConfig.ts"; +import { stripIpv6Brackets } from "./proxyFamily.ts"; + +export const REFUSAL_POLICIES = { + /** The TCP probe could not open a connection to the proxy. */ + proxy_unreachable: { baseMs: 60_000, maxMs: 600_000 }, + /** The provider refused through this proxy; the member is set aside for a cooldown. */ + ip_quota_429: { baseMs: COOLDOWN_MS.rateLimit, maxMs: 3_600_000 }, +} as const; + +export type ProxyRefusalKind = keyof typeof REFUSAL_POLICIES; + +// `seq` orders set-aside events so a cache can tell whether it already saw this one. +type RefusalState = { streak: number; until: number; seq: number }; + +const MAX_ENTRIES = 1000; +const REFUSAL_KINDS = Object.keys(REFUSAL_POLICIES) as ProxyRefusalKind[]; +// Same protocol set and default ports as proxyConfigToUrl() in proxyDispatcher.ts. +const DEFAULT_PORTS: Record = { http: "8080", https: "443", socks5: "1080" }; +const RELAY_TYPES = new Set(["vercel", "deno", "cloudflare"]); +const FAMILY_MARKER = /\?family=(ipv4|ipv6)$/; + +const memory = new Map(); +let refusalSeq = 0; + +const textField = (value: unknown): string => (typeof value === "string" ? value : ""); + +// The port as proxyConfigToUrl() normalizes it: the scheme default when unset, null if invalid. +function configPort(port: unknown, type: string): string | null { + if (!port) return DEFAULT_PORTS[type] ?? ""; + const parsed = Number(port); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? String(parsed) : null; +} + +// A config object as the URL proxyConfigToUrl() would build from it; null when unusable. +function configObjectToUrl(proxy: Record): string | null { + const host = textField(proxy.host); + const type = String(proxy.type || "http").toLowerCase(); + const port = configPort(proxy.port, type); + if (!host || RELAY_TYPES.has(type) || port === null) return null; + const bracketed = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const username = textField(proxy.username); + const password = textField(proxy.password); + const auth = + username || password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : ""; + return `${type}://${auth}${bracketed}:${port}`; +} + +// The port written in the authority, which `new URL()` drops when it is the scheme default. +function explicitPortOf(url: string): string | null { + const start = url.indexOf("://"); + if (start === -1) return null; + const rest = url.slice(start + 3); + const slash = rest.indexOf("/"); + const authority = slash === -1 ? rest : rest.slice(0, slash); + const colon = authority.lastIndexOf(":"); + if (colon === -1 || colon < authority.lastIndexOf("@") || colon < authority.lastIndexOf("]")) { + return null; + } + const port = Number(authority.slice(colon + 1)); + return /^\d+$/.test(authority.slice(colon + 1)) && port >= 1 && port <= 65535 + ? String(port) + : null; +} + +/** + * One key per proxy entry point, whether the proxy comes as a config object, a URL or a + * legacy string: scheme, decoded username, lower-case host without IPv6 brackets, port as + * normalization writes it. Password and ?family= are ignored. Anything unusable, and edge + * relays, give null, which never sets anything aside. + */ +export function proxyEgressKey(proxy: unknown): string | null { + try { + let url: string | null = null; + if (typeof proxy === "string") url = proxy.trim(); + else if (proxy && typeof proxy === "object" && !Array.isArray(proxy)) { + url = configObjectToUrl(proxy as Record); + } + if (!url) return null; + url = url.replace(FAMILY_MARKER, ""); + const parsed = new URL(url); + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); + const defaultPort = DEFAULT_PORTS[scheme]; + if (!defaultPort || !parsed.hostname) return null; + const port = explicitPortOf(url) || parsed.port || defaultPort; + const user = parsed.username ? decodeURIComponent(parsed.username) : ""; + return `${scheme}://${user}@${stripIpv6Brackets(parsed.hostname).toLowerCase()}:${port}`; + } catch { + return null; + } +} + +function entryId(key: string, kind: ProxyRefusalKind): string { + return `${kind} ${key}`; +} + +// Read one (key, kind) state, dropping it once its period ended more than 2 x maxMs ago. +function readState(key: string, kind: ProxyRefusalKind, nowMs: number): RefusalState | undefined { + const id = entryId(key, kind); + const state = memory.get(id); + if (state && nowMs - state.until >= 2 * REFUSAL_POLICIES[kind].maxMs) { + memory.delete(id); + return undefined; + } + return state; +} + +/** Set a proxy aside for `kind`. Returns the new period in ms, or null if nothing changed. */ +export function noteProxyRefusal( + key: string | null, + kind: ProxyRefusalKind, + nowMs: number = Date.now() +): number | null { + if (key === null) return null; + const state = readState(key, kind, nowMs); + if (state && state.until > nowMs) return null; + const policy = REFUSAL_POLICIES[kind]; + const streak = (state?.streak ?? 0) + 1; + const periodMs = Math.min(policy.baseMs * 2 ** (streak - 1), policy.maxMs); + const id = entryId(key, kind); + memory.delete(id); + memory.set(id, { streak, until: nowMs + periodMs, seq: ++refusalSeq }); + if (memory.size > MAX_ENTRIES) { + const oldest = memory.keys().next().value; + if (oldest !== undefined) memory.delete(oldest); + } + return periodMs; +} + +/** The proxy answered again: end its period now, keep the streak so a repeat doubles. */ +export function noteProxyRecovered( + key: string | null, + kind: ProxyRefusalKind, + nowMs: number = Date.now() +): void { + if (key === null) return; + const state = readState(key, kind, nowMs); + if (state && state.until > nowMs) state.until = nowMs; +} + +/** A response came back through this proxy: forget every refusal kind for it. */ +export function noteProxyServed(key: string | null): void { + if (key === null) return; + for (const kind of REFUSAL_KINDS) memory.delete(entryId(key, kind)); +} + +export function isProxyAvoided(key: string | null, nowMs: number = Date.now()): boolean { + return proxySetAsideSeq(key, nowMs) !== null; +} + +/** + * Sequence number of the most recent set-aside event still in force for this proxy, or + * null when it is not set aside. Compare with getProxyRefusalSeq() captured earlier to + * know whether the event happened after that point. + */ +export function proxySetAsideSeq(key: string | null, nowMs: number = Date.now()): number | null { + if (key === null || memory.size === 0) return null; + let latest: number | null = null; + for (const kind of REFUSAL_KINDS) { + const state = readState(key, kind, nowMs); + if (state && state.until > nowMs && (latest === null || state.seq > latest)) { + latest = state.seq; + } + } + return latest; +} + +/** Sequence number of the last set-aside event recorded in this process (0 = none yet). */ +export function getProxyRefusalSeq(): number { + return refusalSeq; +} + +/** True when anything is held at all: lets hot paths skip key computation and flag reads. */ +export function hasProxyRefusals(): boolean { + return memory.size > 0; +} + +/** Test-only: forget everything. */ +export function __resetProxyRefusalMemoryForTesting(): void { + memory.clear(); +} + +/** Test-only: number of (key, kind) entries held. */ +export function __proxyRefusalMemorySizeForTesting(): number { + return memory.size; +} diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index b2c68dffaa..5adfdbefb6 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 301f71b81f..46fbcbdce8 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "الصفحة الرئيسية", "dashboard": "لوحة القيادة", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 65dae94a1d..e4dced592d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 12c57eb2d8..abac4d16fd 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Начало", "dashboard": "Табло", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 12bd9ba46e..9c7315ccc0 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 0415053170..0556c02b58 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Domov", "dashboard": "Nástěnka", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 185b2910a1..207d508beb 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Hjem", "dashboard": "Dashboard", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 848c2b8aee..4590be67df 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Zuhause", "dashboard": "Dashboard", diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 4c38ca2e5c..4530896537 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Αρχική", "dashboard": "Πίνακας Ελέγχου", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3154d1cc55..18d43735ce 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 241c443908..23b2690575 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Inicio", "dashboard": "Panel de control", diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 52745b1b68..646b22d336 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Avaleht", "dashboard": "Juhtpaneel", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index dc7f955a5a..5b103f0686 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 8aa208a9c8..834bfa9e09 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Kotiin", "dashboard": "Kojelauta", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8d49f1294c..60c9fc904f 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Accueil", "dashboard": "Tableau de bord", diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 70180cc0a8..6c8eddea57 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Baile", "dashboard": "Deais", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 2239beca53..142a92fe65 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 000844ada9..b001e3b88b 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 6a034e5a10..07517d0d21 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "בית", "dashboard": "לוח מחוונים", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 79753a8bd3..2e36081dc8 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "घर", "dashboard": "डैशबोर्ड", diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 295870b25b..eea19d6876 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Početna", "dashboard": "Nadzorna ploča", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 3e4e72f51d..c9de63e1c9 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Otthon", "dashboard": "Irányítópult", diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 6494d703d6..b1f75934d6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 53b664ad31..8f404143f1 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Rumah", "dashboard": "Dasbor", diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 1b7a13e90a..410ce9ecac 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 870bf6ef27..dc197fd4ca 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Casa", "dashboard": "Pannello di controllo", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 176f90a4f3..1e6473a22f 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ホーム", "dashboard": "ダッシュボード", diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index c4b22a11a9..df26793a98 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 2f3ffba763..f1f71b0bcb 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ទំព័រដើម", "dashboard": "ផ្ទាំងគ្រប់គ្រង", diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index fba32261a7..eaf37481ff 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ಹೋಮ್", "dashboard": "ಡ್ಯಾಶ್ಬೋರ್ಡ್", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 55d6d4a9df..e0f776c67b 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "홈", "dashboard": "대시보드", diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index e71a97208a..39769831c4 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Pradžia", "dashboard": "Suvestinė", diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 8c20c7f331..24a0ed893e 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Sākums", "dashboard": "Panelis", diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index d6b017c189..8656959e31 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ഹോം", "dashboard": "ഡാഷ്ബോർഡ്", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 21c5196dce..222a45d58c 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 7cef88b648..ecb934436a 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Rumah", "dashboard": "Papan pemuka", diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 424a633b5a..24e69a8d67 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Paġna Ewlenija", "dashboard": "Dashboard", diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 4a5b8d50d5..47aee66053 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ပင်မစာမျက်နှာ", "dashboard": "ဒက်ရှ်ဘုတ်", diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index f9a1e9c081..247a0c51dd 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "गृहपृष्ठ", "dashboard": "ड्यासबोर्ड", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 69b0eda1e9..fa08eb71e2 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Thuis", "dashboard": "Dashboard", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 2dfc51a835..8412d3f6a5 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Hjem", "dashboard": "Dashbord", diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index eaf3aff9cf..0e78e17051 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ମୂଳପୃଷ୍ଠା", "dashboard": "ଡ୍ୟାସ୍ବୋର୍ଡ", diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 2eba360743..cfabb1ef6e 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "ਮੁੱਖ ਪੰਨਾ", "dashboard": "ਡੈਸ਼ਬੋਰਡ", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 9a863eb9a8..504ee6b35c 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Bahay", "dashboard": "Dashboard", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 498a9540a9..817d2373b0 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Strona główna", "dashboard": "Dashboard", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 420ffcfb79..d572e16f7b 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -994,6 +994,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", "sidebar": { "home": "Início", "dashboard": "Painel", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 7e64a830ac..1a272d0c5b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -994,6 +994,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Página inicial", "dashboard": "Painel", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 820b3e1a0d..1f8edbe989 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Acasă", "dashboard": "Tabloul de bord", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 12f6e6184c..d6bb9090a4 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Главная", "dashboard": "Панель управления", diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 46e03fc1cf..2b5a55a057 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "මුල් පිටුව", "dashboard": "උපකරණ පුවරුව", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 5405e2efd0..f128d31776 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Domov", "dashboard": "Dashboard", diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index e271354bc4..ea882a203e 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Domov", "dashboard": "Nadzorna plošča", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 4be757e4a6..7aab7eba24 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Почетна", "dashboard": "Контролна табла", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 95597147fb..68a85a7ad3 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Hem", "dashboard": "Instrumentpanel", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 7659856c24..334ffd8dcb 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 6ebf58a045..726f0f82c0 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index cb56704770..ef792351b9 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index e562496c41..58819f0537 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "บ้าน", "dashboard": "แดชบอร์ด", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 1c7ab39db8..054900127b 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Ana Sayfa", "dashboard": "Kontrol Paneli", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 52b648d099..91e911e4ae 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "додому", "dashboard": "Приладова панель", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 215567a40b..0b646d7420 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 2efade4cf3..3c79019870 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 05158d9a25..e8e3097dc4 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -994,6 +994,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", "sidebar": { "home": "Trang chủ", "dashboard": "Trang tổng quan", diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index 016c628d2d..b24a3e3406 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -14159,5 +14159,6 @@ }, "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." + "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2048f44d28..09bfa410a9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "首页", "dashboard": "仪表板", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a9eb2bbfec..5f2a271e69 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -993,6 +993,7 @@ "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.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "sidebar": { "home": "首頁", "dashboard": "儀表板", diff --git a/src/lib/db/proxies/rotation.ts b/src/lib/db/proxies/rotation.ts index 52cc695c57..853bfd4f98 100644 --- a/src/lib/db/proxies/rotation.ts +++ b/src/lib/db/proxies/rotation.ts @@ -9,6 +9,12 @@ import { randomInt } from "crypto"; import { getDbInstance } from "../core"; import { pickByLatency } from "../proxyLatency"; +import { + hasProxyRefusals, + isProxyAvoided, + proxyEgressKey, +} from "@omniroute/open-sse/utils/proxyRefusalMemory.ts"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; import type { JsonRecord, ProxyScope, ProxyRotationStrategy } from "./types"; import { PROXY_ROTATION_STRATEGIES, DEFAULT_PROXY_ROTATION_STRATEGY } from "./types"; import { @@ -129,11 +135,36 @@ function getOrCreateRotationRow( }; } +// Indexes of the members not currently set aside by the proxy refusal memory, or null to +// keep the plain behavior: nothing set aside, every member set aside (an all-failed pool +// keeps today's selection and its #6246 fail-closed contract), or PROXY_SKIP_RECENTLY_FAILED +// off. The flag is read last, only when skipping would actually change the pick. +function eligibleMemberIndexes(candidates: unknown[]): number[] | null { + if (!hasProxyRefusals()) return null; + const eligible: number[] = []; + candidates.forEach((row, index) => { + if (!isProxyAvoided(proxyEgressKey(row))) eligible.push(index); + }); + if (eligible.length === 0 || eligible.length === candidates.length) return null; + return isProxySkipRecentlyFailedEnabled() ? eligible : null; +} + +// First eligible index at or after `start`, going round the pool. +function firstEligibleFrom(start: number, eligible: number[], size: number): number { + for (let step = 0; step < size; step++) { + const index = (start + step) % size; + if (eligible.includes(index)) return index; + } + return start; +} + /** * Pick one member from an already-alive candidate list according to the scope's * rotation strategy. Assumes `candidates` is non-empty and ordered by position. * Round-robin uses (and persists) a monotonic cursor; random uses crypto.randomInt; * sticky holds the current member until its window elapses, then advances. + * Members that just failed (see proxyRefusalMemory) are skipped while another member is + * eligible; the cursor then advances past the member actually served. */ function pickFromCandidates( db: ReturnType, @@ -144,16 +175,20 @@ function pickFromCandidates( if (candidates.length === 1) return candidates[0]; const state = getOrCreateRotationRow(db, normalizedScope, rotationScopeId); + const eligible = eligibleMemberIndexes(candidates); if (state.strategy === "random") { // crypto.randomInt (unbiased, uniform in [0, length)) instead of Math.random — // CodeQL js/insecure-randomness flags Math.random flowing into the selected proxy's // credentials (a "security context"). Load-balancing selection is not a secret, but // crypto.randomInt silences the alert at the source and is unbiased (#6365 follow-up). + if (eligible) return candidates[eligible[randomInt(eligible.length)]]; return candidates[randomInt(candidates.length)]; } - if (state.strategy === "latency") return pickByLatency(db, candidates); + if (state.strategy === "latency") { + return pickByLatency(db, eligible ? eligible.map((index) => candidates[index]) : candidates); + } if (state.strategy === "sticky") { const windowMs = state.stickyWindowMinutes * 60_000; @@ -173,15 +208,19 @@ function pickFromCandidates( ); } const idx = ((cursor % candidates.length) + candidates.length) % candidates.length; - return candidates[idx]; + // A held member set aside is replaced for this pick only: no extra write. + return candidates[eligible ? firstEligibleFrom(idx, eligible, candidates.length) : idx]; } - // round-robin (default): pick at the current cursor, then advance it monotonically. + // round-robin (default): pick at the current cursor, then advance it monotonically, + // past any member skipped so the next pick starts after the one actually served. const idx = ((state.cursor % candidates.length) + candidates.length) % candidates.length; + const served = eligible ? firstEligibleFrom(idx, eligible, candidates.length) : idx; + const skipped = (served - idx + candidates.length) % candidates.length; db.prepare( "UPDATE proxy_scope_rotation SET cursor = ?, updated_at = ? WHERE scope = ? AND scope_id IS ?" - ).run(state.cursor + 1, new Date().toISOString(), normalizedScope, rotationScopeId); - return candidates[idx]; + ).run(state.cursor + skipped + 1, new Date().toISOString(), normalizedScope, rotationScopeId); + return candidates[served]; } // Fetch the alive, position-ordered candidate rows for a (scope, scope_id) pool. diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index bdc6e6603c..cb4f410579 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -5,6 +5,13 @@ import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { + getProxyRefusalSeq, + hasProxyRefusals, + proxyEgressKey, + proxySetAsideSeq, +} from "@omniroute/open-sse/utils/proxyRefusalMemory.ts"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; import { invalidateDbCache } from "./readCache"; import { encrypt, decrypt } from "./encryption"; import { getProxyRegistryGeneration, resolveProxyForScopeFromRegistry } from "./proxies"; @@ -22,11 +29,14 @@ type ProxyResolutionResult = { levelId: string | null; source?: string; }; -type ProxyResolutionCacheEntry = { +// State observed when a resolution started; an entry is stored only if it still holds. +type ProxyResolutionStamp = { generation: number; registryGeneration: number; - result: ProxyResolutionResult; + // Proxy refusal memory sequence (see isCachedPoolMemberSetAside). + refusalSeq: number; }; +type ProxyResolutionCacheEntry = ProxyResolutionStamp & { result: ProxyResolutionResult }; const PROXY_RESOLUTION_CACHE_MAX_ENTRIES = 100; @@ -44,17 +54,16 @@ export function bumpProxyConfigGeneration() { function cacheProxyResolution( connectionId: string, - generation: number, - registryGeneration: number, + stamp: ProxyResolutionStamp, result: ProxyResolutionResult ) { - if (generation !== proxyConfigGeneration) return; - if (registryGeneration !== getProxyRegistryGeneration()) return; + if (stamp.generation !== proxyConfigGeneration) return; + if (stamp.registryGeneration !== getProxyRegistryGeneration()) return; if (proxyResolutionCache.size >= PROXY_RESOLUTION_CACHE_MAX_ENTRIES) { const oldestKey = proxyResolutionCache.keys().next().value; if (oldestKey) proxyResolutionCache.delete(oldestKey); } - proxyResolutionCache.set(connectionId, { generation, registryGeneration, result }); + proxyResolutionCache.set(connectionId, { ...stamp, result }); } type ProxyMap = Record; @@ -504,6 +513,20 @@ export async function deleteProxyForLevel(level: string, id: string | null) { return setProxyForLevel(level, id, null); } +// With PROXY_SKIP_RECENTLY_FAILED on, a pool member set aside AFTER its resolution started is +// not re-served from the cache: the cascade runs again so the pool can pick another member. +// Once per set-aside event: the new entry records the sequence it started from, so a member +// the pool hands back anyway (every member set aside) is then served from the cache instead +// of costing a DB cascade on every request. Legacy single-proxy levels have no alternative +// and stay cached, like a result without a proxy. The flag is read last, only on a real hit. +function isCachedPoolMemberSetAside(entry: ProxyResolutionCacheEntry): boolean { + const { result } = entry; + if (!hasProxyRefusals() || result.source !== "registry" || result.proxy == null) return false; + const setAsideSeq = proxySetAsideSeq(proxyEgressKey(result.proxy)); + if (setAsideSeq === null || setAsideSeq <= entry.refusalSeq) return false; + return isProxySkipRecentlyFailedEnabled(); +} + export async function resolveProxyForConnection( connectionId: string, apiKeyId?: string, @@ -514,13 +537,17 @@ export async function resolveProxyForConnection( : apiKeyId ? `${connectionId}:${apiKeyId}` : connectionId; - const startGeneration = proxyConfigGeneration; - const startRegistryGeneration = getProxyRegistryGeneration(); + const stamp: ProxyResolutionStamp = { + generation: proxyConfigGeneration, + registryGeneration: getProxyRegistryGeneration(), + refusalSeq: getProxyRefusalSeq(), + }; const cached = proxyResolutionCache.get(cacheKey); if ( cached && - cached.generation === startGeneration && - cached.registryGeneration === startRegistryGeneration + cached.generation === stamp.generation && + cached.registryGeneration === stamp.registryGeneration && + !isCachedPoolMemberSetAside(cached) ) { return cached.result; } @@ -571,7 +598,7 @@ export async function resolveProxyForConnection( // fallback candidates from the proxy pool. if (connectionRecord && !connectionProxyEnabled) { const result: ProxyResolutionResult = { proxy: null, level: "direct", levelId: null }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } @@ -632,7 +659,7 @@ export async function resolveProxyForConnection( levelId: apiKeyId, source: "api_key" as const, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } } @@ -645,7 +672,7 @@ export async function resolveProxyForConnection( // Step 3: Account-level registry const registryAccount = await resolveProxyForScopeFromRegistry("account", connectionId); if (registryAccount?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryAccount); + cacheProxyResolution(cacheKey, stamp, registryAccount); return registryAccount; } @@ -656,7 +683,7 @@ export async function resolveProxyForConnection( level: "key", levelId: connectionId, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } @@ -669,7 +696,7 @@ export async function resolveProxyForConnection( connectionProvider ); if (registryProvider?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryProvider); + cacheProxyResolution(cacheKey, stamp, registryProvider); return registryProvider; } } @@ -699,7 +726,7 @@ export async function resolveProxyForConnection( const registryCombo = await resolveProxyForScopeFromRegistry("combo", comboId); if (registryCombo?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryCombo); + cacheProxyResolution(cacheKey, stamp, registryCombo); return registryCombo; } @@ -709,7 +736,7 @@ export async function resolveProxyForConnection( level: "combo", levelId: comboId, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } } catch { @@ -725,7 +752,7 @@ export async function resolveProxyForConnection( level: "provider", levelId: connectionProvider, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } } @@ -738,7 +765,7 @@ export async function resolveProxyForConnection( if (!connectionRecord) { const noAuthFallback = await resolveNoAuthSharedProviderProxy(config.providers, providerId); if (noAuthFallback) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, noAuthFallback); + cacheProxyResolution(cacheKey, stamp, noAuthFallback); return noAuthFallback; } } @@ -746,14 +773,14 @@ export async function resolveProxyForConnection( // Step 9: Global registry const registryGlobal = await resolveProxyForScopeFromRegistry("global"); if (registryGlobal?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryGlobal); + cacheProxyResolution(cacheKey, stamp, registryGlobal); return registryGlobal; } // Step 10: Legacy global if (config.global) { const result = { proxy: withFamilyDefault(config.global), level: "global", levelId: null }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } @@ -769,12 +796,7 @@ export async function resolveProxyForConnection( fallback.proxy && typeof fallback.proxy === "object" ? { ...fallback, proxy: withFamilyDefault(fallback.proxy as ProxyValue) } : fallback; - cacheProxyResolution( - cacheKey, - startGeneration, - startRegistryGeneration, - normalizedFallback as ProxyResolutionResult - ); + cacheProxyResolution(cacheKey, stamp, normalizedFallback as ProxyResolutionResult); return normalizedFallback; } } catch (err) { diff --git a/src/lib/proxyHealth.ts b/src/lib/proxyHealth.ts index 46e5685032..90aa18b8ea 100644 --- a/src/lib/proxyHealth.ts +++ b/src/lib/proxyHealth.ts @@ -11,6 +11,13 @@ import { createConnection } from "node:net"; import { stripIpv6Brackets } from "@omniroute/open-sse/utils/proxyFamily"; +import { + hasProxyRefusals, + noteProxyRecovered, + noteProxyRefusal, + proxyEgressKey, +} from "@omniroute/open-sse/utils/proxyRefusalMemory"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; // Configurable via env vars const FAST_FAIL_TIMEOUT_MS = parseInt(process.env.PROXY_FAST_FAIL_TIMEOUT_MS ?? "2000", 10); @@ -33,6 +40,19 @@ const proxyHealthInflight = new Map>(); type TcpCheck = (host: string, port: number, timeoutMs: number) => Promise; let tcpCheckImpl: TcpCheck = tcpCheck; +// Feed a real probe verdict to proxy selection (opt-in, PROXY_SKIP_RECENTLY_FAILED): a proxy +// that refused the TCP connection is set aside by pools and account rotation, and taken back +// as soon as it answers again. With the flag off nothing is ever written. +function noteProbeVerdict(proxyUrl: string, healthy: boolean): void { + if (healthy) { + if (hasProxyRefusals()) noteProxyRecovered(proxyEgressKey(proxyUrl), "proxy_unreachable"); + return; + } + if (isProxySkipRecentlyFailedEnabled()) { + noteProxyRefusal(proxyEgressKey(proxyUrl), "proxy_unreachable"); + } +} + /** * T14: Perform a fast TCP check to see if a proxy host:port is reachable. * Results are cached for `cacheTtlMs` (default 30s) to avoid checking every request. @@ -83,6 +103,8 @@ export async function isProxyReachable( } const probe = tcpCheckImpl(host, port, timeoutMs).then((healthy) => { + // Before the cache write, so the verdict's TTL starts after the (flag-gated) note. + noteProbeVerdict(proxyUrl, healthy); proxyHealthCache.set(proxyUrl, { healthy, checkedAt: Date.now(), diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index e125527bfe..28a10a01b5 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -191,6 +191,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "PROXY_SKIP_RECENTLY_FAILED", + label: "Skip Recently Failed Proxies", + description: + "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + descriptionI18nKey: "featureFlagProxySkipRecentlyFailedDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 22bb1caed8..875b158cf9 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -173,6 +173,22 @@ export function isNetworkRotationSharedEgressGuardEnabled(): boolean { } } +/** + * Proxy refusal memory (#13578): pools and account rotation skip a proxy that just failed. + * Opt-in; an unreadable flag store keeps the plain selection. + */ +export function isProxySkipRecentlyFailedEnabled(): boolean { + try { + return isFeatureFlagEnabled("PROXY_SKIP_RECENTLY_FAILED"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve PROXY_SKIP_RECENTLY_FAILED, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 45748ab686..a5b1f3b785 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 61; +const EXPECTED_FEATURE_FLAG_COUNT = 62; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -214,6 +214,17 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "info"); }); + it("defines skip-recently-failed proxies as a network boolean flag disabled by default", () => { + // Guards the routing default: with this on, pools and account rotation skip a proxy + // that just failed. Selection order must stay the plain rotation unless opted in. + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "PROXY_SKIP_RECENTLY_FAILED"); + assert.ok(def, "PROXY_SKIP_RECENTLY_FAILED should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { // Guards the egress default: with this on, /v1/audio/* may reach a provider node // hosted outside localhost. It must never become an implicit default (cf. #3963). diff --git a/tests/unit/opencode-proxy-refusal-memory.test.ts b/tests/unit/opencode-proxy-refusal-memory.test.ts new file mode 100644 index 0000000000..1f3e7644d8 --- /dev/null +++ b/tests/unit/opencode-proxy-refusal-memory.test.ts @@ -0,0 +1,196 @@ +import { describe, it, before, after, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import * as memory from "../../open-sse/utils/proxyRefusalMemory.ts"; + +// With PROXY_SKIP_RECENTLY_FAILED on, a refusal received on a proxied opencode account sets +// that member aside across requests; a direct account is never concerned. With the flag off +// (the default) the rotation is exactly the plain one. + +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FINGERPRINTS = ["a".repeat(32), "b".repeat(32), "c".repeat(32)]; +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < 3; i++) { + const server = net.createServer((socket) => socket.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + for (const server of servers) server.close(); +}); + +function proxyFor(index: number) { + return { type: "http", host: "127.0.0.1", port: ports[index] }; +} + +function keyFor(index: number) { + return memory.proxyEgressKey(proxyFor(index)); +} + +function credentials( + accounts: Array<{ fp: string; proxyIndex: number | null }> +): ProviderCredentials { + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints: accounts.map((a) => a.fp), + accountProxies: accounts.map((a) => ({ + fingerprint: a.fp, + proxy: a.proxyIndex === null ? null : proxyFor(a.proxyIndex), + })), + }, + }; +} + +describe("OpencodeExecutor proxy refusal memory", () => { + let originalFetch: typeof globalThis.fetch; + let observed: string[] = []; + let statuses: number[] = []; + + beforeEach(() => { + originalFetch = globalThis.fetch; + memory.__resetProxyRefusalMemoryForTesting(); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const status = statuses.shift() ?? 200; + return new Response(JSON.stringify({ ok: status === 200 }), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.timers.reset(); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + }); + + const proxied = () => credentials(FINGERPRINTS.map((fp, i) => ({ fp, proxyIndex: i }))); + const port = (index: number) => String(ports[index]); + + async function run(exec: OpencodeExecutor, creds: ProviderCredentials, plan: number[]) { + statuses = [...plan]; + observed = []; + const result = await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: creds, + log, + }); + return { status: (result as { response: Response }).response.status, observed: [...observed] }; + } + + // Account cooldowns are a separate, shorter mechanism: clear them so each assertion shows + // the effect of the proxy memory alone. + function clearCooldowns(exec: OpencodeExecutor) { + const state = exec as unknown as { accounts: Array<{ cooldownUntil: number }> }; + for (const account of state.accounts) account.cooldownUntil = 0; + } + + it("a received refusal sets that member aside for later requests", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const first = await run(exec, proxied(), [429, 200]); + assert.deepStrictEqual(first.observed, [port(0), port(1)]); + assert.strictEqual(first.status, 200); + assert.strictEqual(memory.isProxyAvoided(keyFor(0)), true); + + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(2)]); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(1)]); + }); + + it("with the flag at its default (off) the refused proxy is tried again in turn", async () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + const exec = new OpencodeExecutor("opencode-zen"); + await run(exec, proxied(), [429, 200]); + assert.strictEqual(memory.__proxyRefusalMemorySizeForTesting(), 0); + + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(2)]); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(0)]); + }); + + it("once the period ends the proxy is tried again", async () => { + mock.timers.enable({ apis: ["Date"], now: 1_800_000_000_000 }); + const exec = new OpencodeExecutor("opencode-zen"); + await run(exec, proxied(), [429, 200]); + + mock.timers.tick(2 * 60_000 + 1); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(2)]); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(0)]); + }); + + it("with the flag off a member set aside earlier is not skipped", async () => { + memory.noteProxyRefusal(keyFor(0), "ip_quota_429"); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + const exec = new OpencodeExecutor("opencode-zen"); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(0)]); + }); + + it("when every proxy is set aside one attempt still happens and its success clears it", async () => { + for (let i = 0; i < 3; i++) memory.noteProxyRefusal(keyFor(i), "ip_quota_429"); + const exec = new OpencodeExecutor("opencode-zen"); + + const result = await run(exec, proxied(), [200]); + assert.strictEqual(result.status, 200); + assert.deepStrictEqual(result.observed, [port(0)]); + assert.strictEqual(memory.isProxyAvoided(keyFor(0)), false); + assert.strictEqual(memory.isProxyAvoided(keyFor(1)), true); + }); + + it("a refusal on a proxyless account writes nothing, direct stays eligible", async () => { + const mixed = () => + credentials([ + { fp: FINGERPRINTS[0], proxyIndex: null }, + { fp: FINGERPRINTS[1], proxyIndex: 1 }, + ]); + const exec = new OpencodeExecutor("opencode-zen"); + + const first = await run(exec, mixed(), [429, 200]); + assert.deepStrictEqual(first.observed, ["direct", port(1)]); + assert.strictEqual(memory.__proxyRefusalMemorySizeForTesting(), 0); + + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, mixed(), [200])).observed, ["direct"]); + }); + + it("a connection without configured accounts never touches the memory", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const noAccounts: ProviderCredentials = { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: {}, + }; + // The fast path may retry a refusal internally: every planned answer is a refusal. + await run(exec, noAccounts, [429, 429, 429, 429, 429]); + assert.strictEqual(memory.__proxyRefusalMemorySizeForTesting(), 0); + }); +}); diff --git a/tests/unit/proxy-health-refusal-memory.test.ts b/tests/unit/proxy-health-refusal-memory.test.ts new file mode 100644 index 0000000000..43f84a8010 --- /dev/null +++ b/tests/unit/proxy-health-refusal-memory.test.ts @@ -0,0 +1,64 @@ +import test, { mock } from "node:test"; +import assert from "node:assert/strict"; + +// The TCP reachability probe already runs for every proxied request. With the opt-in +// PROXY_SKIP_RECENTLY_FAILED flag on, its verdict feeds proxy selection: a refused probe sets +// the proxy aside, a successful one takes it back. With the flag off nothing is written. + +const health = await import("../../src/lib/proxyHealth.ts"); +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); + +const PROXY_URL = "http://10.7.0.1:8080"; +const KEY = memory.proxyEgressKey(PROXY_URL); + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); + health.invalidateProxyHealth(PROXY_URL); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; +}); + +test.afterEach(() => { + mock.timers.reset(); + health.__setProxyHealthTcpCheckForTesting(null); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; +}); + +test("a refused probe sets the proxy aside for 60 s, then 120 s on a repeat", async () => { + mock.timers.enable({ apis: ["Date"], now: 1_800_000_000_000 }); + health.__setProxyHealthTcpCheckForTesting(async () => false); + + assert.equal(await health.isProxyReachable(PROXY_URL), false); + const first = Date.now(); + assert.equal(memory.isProxyAvoided(KEY, first + 59_999), true); + assert.equal(memory.isProxyAvoided(KEY, first + 60_000), false); + + mock.timers.tick(61_000); + health.invalidateProxyHealth(PROXY_URL); + assert.equal(await health.isProxyReachable(PROXY_URL), false); + const second = Date.now(); + assert.equal(memory.isProxyAvoided(KEY, second + 119_999), true); + assert.equal(memory.isProxyAvoided(KEY, second + 120_000), false); +}); + +test("a probe that answers again ends the period", async () => { + health.__setProxyHealthTcpCheckForTesting(async () => false); + await health.isProxyReachable(PROXY_URL); + assert.equal(memory.isProxyAvoided(KEY), true); + + health.invalidateProxyHealth(PROXY_URL); + health.__setProxyHealthTcpCheckForTesting(async () => true); + assert.equal(await health.isProxyReachable(PROXY_URL), true); + assert.equal(memory.isProxyAvoided(KEY), false); +}); + +test("with the flag at its default (off) a refused probe writes nothing", async () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + health.__setProxyHealthTcpCheckForTesting(async () => false); + assert.equal(await health.isProxyReachable(PROXY_URL), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("a malformed proxy URL writes nothing", async () => { + assert.equal(await health.isProxyReachable("not a url"), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); diff --git a/tests/unit/proxy-pool-skips-refused-member.test.ts b/tests/unit/proxy-pool-skips-refused-member.test.ts new file mode 100644 index 0000000000..c2dc6c293c --- /dev/null +++ b/tests/unit/proxy-pool-skips-refused-member.test.ts @@ -0,0 +1,233 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// With PROXY_SKIP_RECENTLY_FAILED on, pool selection skips members that just failed, for +// every rotation strategy, and the per-connection resolution cache stops re-serving such a +// member (once per set-aside event, never a DB cascade per request). With every member set +// aside, or the flag off (the default), selection is exactly what it was. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-skip-refused-")); +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 settingsDb = await import("../../src/lib/db/settings.ts"); +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); +const flagsDb = await import("../../src/lib/db/featureFlags.ts"); + +function resetStorage() { + memory.__resetProxyRefusalMemoryForTesting(); + // Opt in for every test; the flag-off tests remove it explicitly. + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; + 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(() => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + memory.__resetProxyRefusalMemoryForTesting(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +type Member = { id: string; host: string; port: number }; +let seq = 0; + +async function pool(size: number, scope = "provider", scopeId = "openai"): Promise { + const members: Member[] = []; + for (let i = 0; i < size; i++) { + seq++; + const host = `10.8.0.${seq}`; + const port = 9100 + seq; + const proxy = await proxiesDb.createProxy({ name: `member ${seq}`, type: "http", host, port }); + await proxiesDb.addProxyToScopePool(scope, scopeId, proxy.id); + members.push({ id: proxy.id, host, port }); + } + return members; +} + +function keyOf(member: Member) { + return memory.proxyEgressKey({ type: "http", host: member.host, port: member.port }); +} + +function setAside(member: Member) { + memory.noteProxyRefusal(keyOf(member), "proxy_unreachable"); +} + +async function pick(scope = "provider", scopeId = "openai") { + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry(scope, scopeId); + return (resolved as { proxy: { host: string } }).proxy.host; +} + +async function picks(count: number) { + const hosts: string[] = []; + for (let i = 0; i < count; i++) hosts.push(await pick()); + return hosts; +} + +function rotationRow() { + return core + .getDbInstance() + .prepare( + "SELECT cursor, rotated_at FROM proxy_scope_rotation WHERE scope = 'provider' AND scope_id IS 'openai'" + ) + .get() as { cursor: number; rotated_at: string | null }; +} + +test("round-robin skips a member set aside: A, C, A, C", async () => { + const [a, b, c] = await pool(3); + setAside(b); + assert.deepEqual(await picks(4), [a.host, c.host, a.host, c.host]); +}); + +test("round-robin wraps past the last member and advances beyond the one served", async () => { + const [a, b, c] = await pool(3); + await pick(); + core + .getDbInstance() + .prepare( + "UPDATE proxy_scope_rotation SET cursor = 2 WHERE scope = 'provider' AND scope_id IS 'openai'" + ) + .run(); + setAside(c); + assert.equal(await pick(), a.host); + assert.equal(rotationRow().cursor, 4); + assert.equal(await pick(), b.host); +}); + +test("sticky moves to the next eligible member without writing the rotation row", async () => { + const [, b, c] = await pool(3); + await proxiesDb.setScopeRotationStrategy("provider", "openai", "sticky", { + stickyWindowMinutes: 30, + }); + const held = await pick(); + const before = rotationRow(); + const heldMember = [b, c].find((m) => m.host === held) ?? null; + assert.ok(heldMember, `sticky first pick should be B or C, got ${held}`); + setAside(heldMember); + + const next = await pick(); + assert.notEqual(next, held); + assert.deepEqual(rotationRow(), before); +}); + +test("random never draws a member set aside", async () => { + const [a] = await pool(3); + await proxiesDb.setScopeRotationStrategy("provider", "openai", "random"); + setAside(a); + for (let i = 0; i < 1000; i++) assert.notEqual(await pick(), a.host); +}); + +test("latency picks among the eligible members", async () => { + const [a, b] = await pool(3); + await proxiesDb.setScopeRotationStrategy("provider", "openai", "latency"); + setAside(a); + assert.equal(await pick(), b.host); +}); + +test("with every member set aside the pool behaves as before", async () => { + const members = await pool(3); + for (const member of members) setAside(member); + assert.deepEqual( + await picks(3), + members.map((m) => m.host) + ); +}); + +test("with the flag at its default (off) a member set aside is still served in turn", async () => { + const members = await pool(3); + setAside(members[1]); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + assert.deepEqual( + await picks(3), + members.map((m) => m.host) + ); + assert.equal(rotationRow().cursor, 3); +}); + +test("a DB override turning the flag off wins over the environment", async () => { + const members = await pool(3); + setAside(members[1]); + flagsDb.setFeatureFlagOverride("PROXY_SKIP_RECENTLY_FAILED", "false"); + assert.deepEqual( + await picks(3), + members.map((m) => m.host) + ); + flagsDb.setFeatureFlagOverride("PROXY_SKIP_RECENTLY_FAILED", "true"); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + assert.deepEqual(await picks(2), [members[0].host, members[2].host]); +}); + +test("a connection's cached pool member is not re-served once set aside", async () => { + const [a, b, c] = await pool(3, "account", "conn-pool"); + const first = await settingsDb.resolveProxyForConnection("conn-pool"); + assert.equal((first as { proxy: { host: string } }).proxy.host, a.host); + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-pool"), first); + + setAside(a); + const next = await settingsDb.resolveProxyForConnection("conn-pool"); + assert.equal((next as { proxy: { host: string } }).proxy.host, b.host); + + memory.noteProxyRecovered(keyOf(a), "proxy_unreachable"); + assert.equal(memory.isProxyAvoided(keyOf(a)), false); + assert.deepEqual( + [await pick("account", "conn-pool"), await pick("account", "conn-pool")], + [c.host, a.host] + ); +}); + +test("with the flag off a connection keeps its cached pool member even once set aside", async () => { + const [a] = await pool(3, "account", "conn-off"); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + const first = await settingsDb.resolveProxyForConnection("conn-off"); + assert.equal((first as { proxy: { host: string } }).proxy.host, a.host); + setAside(a); + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-off"), first); +}); + +test("with every member set aside the cascade re-runs once, not on every request", async () => { + // Round-robin advances its persisted cursor on each cascade run, so the cursor counts + // how many times the registry pool was actually queried for this connection. + const [a, b] = await pool(2, "account", "conn-all"); + const cursor = () => + ( + core + .getDbInstance() + .prepare( + "SELECT cursor FROM proxy_scope_rotation WHERE scope = 'account' AND scope_id IS 'conn-all'" + ) + .get() as { cursor: number } + ).cursor; + + const first = await settingsDb.resolveProxyForConnection("conn-all"); + assert.equal((first as { proxy: { host: string } }).proxy.host, a.host); + assert.equal(cursor(), 1); + + setAside(a); + setAside(b); + const second = await settingsDb.resolveProxyForConnection("conn-all"); + assert.equal((second as { proxy: { host: string } }).proxy.host, b.host); + assert.equal(cursor(), 2); + + for (let i = 0; i < 5; i++) { + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-all"), second); + } + assert.equal(cursor(), 2, "a member set aside before the entry was cached must not bypass it"); +}); + +test("a legacy single-proxy level stays cached even when its proxy is set aside", async () => { + await settingsDb.setProxyForLevel("key", "conn-legacy", "http://10.9.9.9:8080"); + const first = await settingsDb.resolveProxyForConnection("conn-legacy"); + assert.equal((first as { level: string }).level, "key"); + memory.noteProxyRefusal(memory.proxyEgressKey("http://10.9.9.9:8080"), "proxy_unreachable"); + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-legacy"), first); +}); diff --git a/tests/unit/proxy-refusal-memory.test.ts b/tests/unit/proxy-refusal-memory.test.ts new file mode 100644 index 0000000000..9f940f7c38 --- /dev/null +++ b/tests/unit/proxy-refusal-memory.test.ts @@ -0,0 +1,199 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// A per-process memory of proxies that just failed, shared by pool selection and the +// per-account rotation. One canonical key per entry point, a period that doubles on each +// repeat up to a cap, and a null key that never sets anything aside. The store is pure: +// whether it is consulted is decided by the PROXY_SKIP_RECENTLY_FAILED flag at call sites. + +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); + +const MIN = 60_000; +const START_MS = 1_800_000_000_000; + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); +}); + +test("an object, its URL and a legacy string give the same key", () => { + const fromObject = memory.proxyEgressKey({ + type: "http", + host: "H", + port: 8080, + username: "a@b", + password: "pw", + }); + assert.equal(fromObject, "http://a@b@h:8080"); + assert.equal(memory.proxyEgressKey("http://a%40b:pw@h:8080"), fromObject); + assert.equal(memory.proxyEgressKey("http://a%40b:pw@H:8080"), fromObject); +}); + +test("the password and the family marker are not part of the key, the username is", () => { + const base = memory.proxyEgressKey("http://a%40b:pw@h:8080"); + assert.equal(memory.proxyEgressKey("http://a%40b:other@h:8080"), base); + assert.equal(memory.proxyEgressKey("http://a%40b:pw@h:8080?family=ipv6"), base); + assert.notEqual(memory.proxyEgressKey("http://c:pw@h:8080"), base); +}); + +test("an IPv6 host gives the same non-null key as an object or a URL", () => { + const fromObject = memory.proxyEgressKey({ type: "http", host: "::1", port: 8080 }); + assert.equal(fromObject, "http://@::1:8080"); + assert.equal(memory.proxyEgressKey("http://[::1]:8080"), fromObject); +}); + +test("invalid input, null, undefined and relays give a null key without throwing", () => { + for (const input of ["not a url", null, undefined, { type: "http" }, 42]) { + assert.equal(memory.proxyEgressKey(input), null, String(input)); + } + assert.equal(memory.proxyEgressKey({ type: "vercel", host: "x.vercel.app", port: 443 }), null); +}); + +test("repeated refusals double the period up to the one-hour cap", () => { + const key = "http://@h:8080"; + const periods: Array = []; + let now = START_MS; + for (let i = 0; i < 7; i++) { + const period = memory.noteProxyRefusal(key, "ip_quota_429", now); + periods.push(period); + now += period ?? 0; + } + assert.deepEqual(periods, [2 * MIN, 4 * MIN, 8 * MIN, 16 * MIN, 32 * MIN, 60 * MIN, 60 * MIN]); +}); + +test("a note while the proxy is set aside changes nothing", () => { + const key = "http://@h:8080"; + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", START_MS), 2 * MIN); + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", START_MS + MIN), null); + assert.equal(memory.isProxyAvoided(key, START_MS + 2 * MIN - 1), true); + assert.equal(memory.isProxyAvoided(key, START_MS + 2 * MIN), false); +}); + +test("recovery ends the period but keeps the streak for a repeat", () => { + const key = "http://@h:8080"; + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + memory.noteProxyRecovered(key, "proxy_unreachable", START_MS + 10_000); + assert.equal(memory.isProxyAvoided(key, START_MS + 10_000), false); + assert.equal(memory.noteProxyRefusal(key, "proxy_unreachable", START_MS + 20_000), 2 * MIN); +}); + +test("a served response forgets every refusal kind for that key", () => { + const key = "http://@h:8080"; + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + memory.noteProxyRefusal(key, "ip_quota_429", START_MS); + memory.noteProxyServed(key); + assert.equal(memory.isProxyAvoided(key, START_MS + 1), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("an old streak is purged per kind: unreachable 20 min, refusal 2 h", () => { + const key = "http://@h:8080"; + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + const endUnreachable = START_MS + MIN; + assert.equal( + memory.noteProxyRefusal(key, "proxy_unreachable", endUnreachable + 19 * MIN), + 2 * MIN + ); + + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + assert.equal(memory.noteProxyRefusal(key, "proxy_unreachable", endUnreachable + 20 * MIN), MIN); + + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(key, "ip_quota_429", START_MS); + const endRefusal = START_MS + 2 * MIN; + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", endRefusal + 119 * MIN), 4 * MIN); + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(key, "ip_quota_429", START_MS); + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", endRefusal + 120 * MIN), 2 * MIN); +}); + +test("a null key never writes and is never set aside", () => { + assert.equal(memory.noteProxyRefusal(null, "ip_quota_429", START_MS), null); + memory.noteProxyRecovered(null, "proxy_unreachable", START_MS); + memory.noteProxyServed(null); + assert.equal(memory.isProxyAvoided(null, START_MS), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("the memory keeps at most 1000 entries and evicts the oldest", () => { + for (let i = 0; i < 1001; i++) { + memory.noteProxyRefusal(`http://@h:${10000 + i}`, "ip_quota_429", START_MS); + } + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 1000); + assert.equal(memory.isProxyAvoided("http://@h:10000", START_MS + 1), false); + assert.equal(memory.isProxyAvoided("http://@h:11000", START_MS + 1), true); +}); + +test("the key matches the one derived from the dispatcher's normalized proxy URL", async () => { + // The memory module computes keys without importing the proxy dispatcher (so the DB layer + // can consult it cheaply). Guard against drift from proxyConfigToUrl() normalization. + const { proxyConfigToUrl } = await import("../../open-sse/utils/proxyDispatcher.ts"); + const authority = /^([a-z0-9+.-]+):\/\/(?:([^@/]*)@)?(\[[^\]]+\]|[^:/?#]+):(\d+)/i; + const viaDispatcher = (input: unknown) => { + let normalizedInput = input; + if (input && typeof input === "object") { + const host = (input as { host?: string }).host; + if (typeof host === "string" && host.includes(":") && !host.startsWith("[")) { + normalizedInput = { ...(input as object), host: `[${host}]` }; + } + } + const url = proxyConfigToUrl(normalizedInput, { allowSocks5: true }); + const match = url ? authority.exec(url) : null; + if (!match) return null; + const [, scheme, userinfo, host, port] = match; + const user = userinfo ? decodeURIComponent(userinfo.split(":")[0]) : ""; + const bare = host.startsWith("[") ? host.slice(1, -1) : host; + return `${scheme.toLowerCase()}://${user}@${bare.toLowerCase()}:${port}`; + }; + const inputs: unknown[] = [ + { type: "http", host: "Proxy.Example.com", port: 3128, username: "u s", password: "p" }, + { type: "https", host: "h", port: "443" }, + { type: "socks5", host: "10.0.0.2", username: "a@b", password: "x:y" }, + { type: "http", host: "h" }, + { type: "http", host: "2001:db8::1", port: 8080, family: "ipv6" }, + { host: "h", port: 80 }, + "http://user:pw@H:80", + "https://h", + "socks5://a%40b:pw@10.0.0.3:1080?family=ipv4", + "http://[2001:db8::2]:3128", + "http://h:8080/", + ]; + for (const input of inputs) { + assert.equal(memory.proxyEgressKey(input), viaDispatcher(input), JSON.stringify(input)); + } +}); + +test("an out-of-range port or an unsupported scheme gives a null key", () => { + assert.equal(memory.proxyEgressKey({ type: "http", host: "h", port: 70000 }), null); + assert.equal(memory.proxyEgressKey({ type: "ftp", host: "h", port: 21 }), null); + assert.equal(memory.proxyEgressKey("ftp://h:21"), null); +}); + +test("set-aside events are ordered, and only the one in force is reported", () => { + const a = "http://@a:8080"; + const b = "http://@b:8080"; + assert.equal(memory.hasProxyRefusals(), false); + assert.equal(memory.proxySetAsideSeq(a, START_MS), null); + const before = memory.getProxyRefusalSeq(); + + memory.noteProxyRefusal(a, "proxy_unreachable", START_MS); + const seqA = memory.proxySetAsideSeq(a, START_MS + 1); + assert.ok(seqA !== null && seqA > before); + assert.equal(memory.hasProxyRefusals(), true); + + // A note while already set aside records no new event. + memory.noteProxyRefusal(a, "proxy_unreachable", START_MS + 2); + assert.equal(memory.proxySetAsideSeq(a, START_MS + 3), seqA); + + memory.noteProxyRefusal(b, "ip_quota_429", START_MS + 4); + const seqB = memory.proxySetAsideSeq(b, START_MS + 5); + assert.ok(seqB !== null && seqB > seqA); + assert.equal(memory.getProxyRefusalSeq(), seqB); + + // A second kind on the same proxy reports the most recent event. + memory.noteProxyRefusal(a, "ip_quota_429", START_MS + 6); + assert.equal(memory.proxySetAsideSeq(a, START_MS + 7), memory.getProxyRefusalSeq()); + + // Once every period is over nothing is in force. + assert.equal(memory.proxySetAsideSeq(b, START_MS + 4 + 2 * MIN), null); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index c837a17caa..9aa46264d8 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, 61); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 62); }); }); From 238cb1b0769d7d0625b45b8f662ca6e3da65cef8 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:26:15 +0200 Subject: [PATCH 26/36] feat(proxy-logs): keep the HTTP status the provider actually returned (#13580) `proxy_logs` records `upstream_status`, the HTTP status the provider actually returned through the proxy, instead of only success/timeout/error. Maintainer rework before merge (kept the idea, no default behavior change): - The migration collided with the tip (177 was already taken): renumbered to `179_proxy_logs_upstream_status.sql`, the runner's already-applied check moved to `case "179"` (the old `"177"` would have skipped the tip's own 177), migration count bumped to 176 in README, AGENTS.md, llm.txt and its mirrors (operator-approved). - A new test runs the real migration runner on the real SQL files and fails with the old case number. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- AGENTS.md | 2 +- README.md | 2 +- .../13580-proxy-log-upstream-status.md | 1 + config/quality/file-size-baseline.json | 5 +- docs/i18n/am/llm.txt | 9 +- docs/i18n/ar/llm.txt | 8 +- docs/i18n/az/llm.txt | 8 +- docs/i18n/bg/llm.txt | 8 +- docs/i18n/bn/llm.txt | 8 +- docs/i18n/cs/llm.txt | 8 +- docs/i18n/da/llm.txt | 8 +- docs/i18n/de/llm.txt | 8 +- docs/i18n/el/llm.txt | 8 +- docs/i18n/es/llm.txt | 8 +- docs/i18n/et/llm.txt | 8 +- docs/i18n/fa/llm.txt | 8 +- docs/i18n/fi/llm.txt | 8 +- docs/i18n/fr/llm.txt | 8 +- docs/i18n/ga/llm.txt | 8 +- docs/i18n/gu/llm.txt | 8 +- docs/i18n/ha/llm.txt | 9 +- docs/i18n/he/llm.txt | 8 +- docs/i18n/hi/llm.txt | 8 +- docs/i18n/hr/llm.txt | 8 +- docs/i18n/hu/llm.txt | 8 +- docs/i18n/hy/llm.txt | 9 +- docs/i18n/id/llm.txt | 8 +- docs/i18n/ig/llm.txt | 9 +- docs/i18n/it/llm.txt | 8 +- docs/i18n/ja/llm.txt | 8 +- docs/i18n/ka/llm.txt | 9 +- docs/i18n/km/llm.txt | 8 +- docs/i18n/kn/llm.txt | 8 +- docs/i18n/ko/llm.txt | 8 +- docs/i18n/lt/llm.txt | 8 +- docs/i18n/lv/llm.txt | 8 +- docs/i18n/ml/llm.txt | 8 +- docs/i18n/mr/llm.txt | 8 +- docs/i18n/ms/llm.txt | 8 +- docs/i18n/mt/llm.txt | 8 +- docs/i18n/my/llm.txt | 8 +- docs/i18n/ne/llm.txt | 8 +- docs/i18n/nl/llm.txt | 8 +- docs/i18n/no/llm.txt | 8 +- docs/i18n/or/llm.txt | 8 +- docs/i18n/pa/llm.txt | 8 +- docs/i18n/phi/llm.txt | 8 +- docs/i18n/pl/llm.txt | 8 +- docs/i18n/pt-BR/llm.txt | 8 +- docs/i18n/pt/llm.txt | 8 +- docs/i18n/ro/llm.txt | 8 +- docs/i18n/ru/llm.txt | 8 +- docs/i18n/si/llm.txt | 8 +- docs/i18n/sk/llm.txt | 8 +- docs/i18n/sl/llm.txt | 8 +- docs/i18n/sr/llm.txt | 8 +- docs/i18n/sv/llm.txt | 8 +- docs/i18n/sw/llm.txt | 8 +- docs/i18n/ta/llm.txt | 8 +- docs/i18n/te/llm.txt | 8 +- docs/i18n/th/llm.txt | 8 +- docs/i18n/tr/llm.txt | 8 +- docs/i18n/uk-UA/llm.txt | 8 +- docs/i18n/ur/llm.txt | 8 +- docs/i18n/uz/llm.txt | 9 +- docs/i18n/vi/llm.txt | 8 +- docs/i18n/yo/llm.txt | 9 +- docs/i18n/zh-CN/llm.txt | 8 +- docs/i18n/zh-TW/llm.txt | 8 +- llm.txt | 8 +- open-sse/utils/providerRequestLogging.ts | 49 +++++- open-sse/utils/proxyFetch.ts | 8 +- open-sse/utils/upstreamStatusCapture.ts | 43 ++++++ src/lib/db/migrationRunner.ts | 5 + .../179_proxy_logs_upstream_status.sql | 4 + src/lib/db/schemaColumns.ts | 4 + src/lib/proxyLogger.ts | 9 +- src/sse/handlers/chat.ts | 6 +- src/sse/handlers/chatHelpers.ts | 22 +++ tests/unit/merge-applied-proxy-sink.test.ts | 113 ++++++++++++++ ...ion-179-proxy-logs-upstream-status.test.ts | 81 ++++++++++ tests/unit/proxy-logs-upstream-status.test.ts | 86 +++++++++++ ...proxyfetch-upstream-status-capture.test.ts | 143 ++++++++++++++++++ tests/unit/upstream-status-capture.test.ts | 130 ++++++++++++++++ 84 files changed, 973 insertions(+), 275 deletions(-) create mode 100644 changelog.d/features/13580-proxy-log-upstream-status.md create mode 100644 open-sse/utils/upstreamStatusCapture.ts create mode 100644 src/lib/db/migrations/179_proxy_logs_upstream_status.sql create mode 100644 tests/unit/merge-applied-proxy-sink.test.ts create mode 100644 tests/unit/migration-179-proxy-logs-upstream-status.test.ts create mode 100644 tests/unit/proxy-logs-upstream-status.test.ts create mode 100644 tests/unit/proxyfetch-upstream-status-capture.test.ts create mode 100644 tests/unit/upstream-status-capture.test.ts diff --git a/AGENTS.md b/AGENTS.md index 947377b8d1..69e81b7ab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below. | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (175 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (176 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/README.md b/README.md index 3208e32b04..edca9ae592 100644 --- a/README.md +++ b/README.md @@ -1268,7 +1268,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 175 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 176 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/changelog.d/features/13580-proxy-log-upstream-status.md b/changelog.d/features/13580-proxy-log-upstream-status.md new file mode 100644 index 0000000000..bf3e5ea683 --- /dev/null +++ b/changelog.d/features/13580-proxy-log-upstream-status.md @@ -0,0 +1 @@ +- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 4db0a02179..b2f30f413a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,7 +1,9 @@ { + "_rebaseline_2026_09_15_13580_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1202. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_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_13_13580_proxy_log_upstream_status": "PR #13580 own growth: open-sse/utils/proxyFetch.ts 1271->1275 (+4 = one import, one blank line, the applied-proxy sink getter and the const that wraps the existing fetch patch with withUpstreamStatusCapture). Irreducible plumbing at the single fetch install site: every return path of the patched fetch (dispatchers, direct sentinel, TLS layer, relay, native fallback, Bun) is covered by one wrapper instead of instrumenting each return. The capture logic lives in the new open-sse/utils/upstreamStatusCapture.ts (under cap). Covered by tests/unit/upstream-status-capture.test.ts and tests/unit/proxyfetch-upstream-status-capture.test.ts. Rework 2026-09-15: the migration is renumbered 177->179 (tip already ships 177/178), no size change.", "_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.", @@ -340,6 +342,7 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", "frozen": { + "src/sse/handlers/chatHelpers.ts": 1202, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index b2a1ae93e3..9134543a98 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 70db156845..0ed9ba4afe 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 23fe71dece..6ec72e9b07 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 52db37b7eb..3f682bf3b7 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index 39bebfcc55..0846e63050 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index ba70410f19..28c6c9004b 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 93ab0c4b82..b7c8f543cb 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/el/llm.txt b/docs/i18n/el/llm.txt index 740c213da8..bb42596d24 100644 --- a/docs/i18n/el/llm.txt +++ b/docs/i18n/el/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index ead2cc4113..35277266c1 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/et/llm.txt b/docs/i18n/et/llm.txt index 16fa187a52..05ea61696e 100644 --- a/docs/i18n/et/llm.txt +++ b/docs/i18n/et/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 6dae87ab96..10c736125b 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index b0feeb0fb3..b90155fe90 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 942d8c5934..b4575e8b23 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ga/llm.txt b/docs/i18n/ga/llm.txt index c65ade94d9..000ec04984 100644 --- a/docs/i18n/ga/llm.txt +++ b/docs/i18n/ga/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index d4b7b37d1b..625e9d85e3 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ha/llm.txt b/docs/i18n/ha/llm.txt index 9c336597b4..c1c8f69cb1 100644 --- a/docs/i18n/ha/llm.txt +++ b/docs/i18n/ha/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 45879f183a..e3bbdcd90d 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 358803cf51..e38bc37c27 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hr/llm.txt b/docs/i18n/hr/llm.txt index 85bc94fd10..8ef00fa5c7 100644 --- a/docs/i18n/hr/llm.txt +++ b/docs/i18n/hr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index bbb7d0e0f2..106c2b25a7 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hy/llm.txt b/docs/i18n/hy/llm.txt index d713242700..4de463ecd4 100644 --- a/docs/i18n/hy/llm.txt +++ b/docs/i18n/hy/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 309dcd1169..ccfb8f192d 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ig/llm.txt b/docs/i18n/ig/llm.txt index 895c950c95..27c0f08db8 100644 --- a/docs/i18n/ig/llm.txt +++ b/docs/i18n/ig/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index 787c31b404..586c70bf9e 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 25ddac8890..42aa3211aa 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ka/llm.txt b/docs/i18n/ka/llm.txt index fbcc262642..b87709baa1 100644 --- a/docs/i18n/ka/llm.txt +++ b/docs/i18n/ka/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/km/llm.txt b/docs/i18n/km/llm.txt index d765584847..c559c1c069 100644 --- a/docs/i18n/km/llm.txt +++ b/docs/i18n/km/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/kn/llm.txt b/docs/i18n/kn/llm.txt index d9b80fdda5..8b90b5bd75 100644 --- a/docs/i18n/kn/llm.txt +++ b/docs/i18n/kn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index f2f0b41d39..e2c88a3bc8 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/lt/llm.txt b/docs/i18n/lt/llm.txt index 4d0623903c..30c84480ef 100644 --- a/docs/i18n/lt/llm.txt +++ b/docs/i18n/lt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/lv/llm.txt b/docs/i18n/lv/llm.txt index ec1f7ceaf9..a10e5cfb44 100644 --- a/docs/i18n/lv/llm.txt +++ b/docs/i18n/lv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ml/llm.txt b/docs/i18n/ml/llm.txt index 8aa3e4dbf8..c35b9e0c3f 100644 --- a/docs/i18n/ml/llm.txt +++ b/docs/i18n/ml/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 5369de769a..267d20e389 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index aab979e702..1cecd2685f 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mt/llm.txt b/docs/i18n/mt/llm.txt index 60e1d2d828..06b08636c9 100644 --- a/docs/i18n/mt/llm.txt +++ b/docs/i18n/mt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/my/llm.txt b/docs/i18n/my/llm.txt index 4e912ba476..7f5052c484 100644 --- a/docs/i18n/my/llm.txt +++ b/docs/i18n/my/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ne/llm.txt b/docs/i18n/ne/llm.txt index ee9a879b25..7e3c7b9afe 100644 --- a/docs/i18n/ne/llm.txt +++ b/docs/i18n/ne/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index f4003a7aa9..0f60572235 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index b86e8b3814..d800a7dddc 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/or/llm.txt b/docs/i18n/or/llm.txt index 4e0e0df770..ec6ac7fce7 100644 --- a/docs/i18n/or/llm.txt +++ b/docs/i18n/or/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pa/llm.txt b/docs/i18n/pa/llm.txt index e2342e8caf..b55f4ae03d 100644 --- a/docs/i18n/pa/llm.txt +++ b/docs/i18n/pa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index bb97e5f7eb..9fc54131b0 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 23a1b5a3b0..e78a340602 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index ccd0d779da..1bec9af6e6 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 659b94dd1d..31a8ae5a12 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index eee7e13ae4..d723831165 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 3677f6727d..ada7cab271 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/si/llm.txt b/docs/i18n/si/llm.txt index d4adb7c4db..663f79b27f 100644 --- a/docs/i18n/si/llm.txt +++ b/docs/i18n/si/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index b9d05cf329..d6c5098f0a 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sl/llm.txt b/docs/i18n/sl/llm.txt index 2db02bc74f..7a6e4b2a6d 100644 --- a/docs/i18n/sl/llm.txt +++ b/docs/i18n/sl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sr/llm.txt b/docs/i18n/sr/llm.txt index 25b151b122..56bc46640c 100644 --- a/docs/i18n/sr/llm.txt +++ b/docs/i18n/sr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 6c9bd92b24..7d4698e370 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 0ee02966f2..7d041ae8e9 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index c318001ff4..a6c2c2852d 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 660136519b..4b7315bc31 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 5f3f258b98..c363c854bb 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 84c4a26fb2..2cf5ea45dc 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index bd282832b4..253b94e9f5 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index d8b45c0cec..304f6d87fa 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uz/llm.txt b/docs/i18n/uz/llm.txt index 74b47a25cc..e9217f5d03 100644 --- a/docs/i18n/uz/llm.txt +++ b/docs/i18n/uz/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 180106850f..6a7c3c25c7 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/yo/llm.txt b/docs/i18n/yo/llm.txt index 82d352c8cb..9956519f16 100644 --- a/docs/i18n/yo/llm.txt +++ b/docs/i18n/yo/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index b6ff51e032..9f130ca59b 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 22e134c685..be78d6b4a5 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/llm.txt b/llm.txt index 4ff0db6357..4d7f1d1f63 100644 --- a/llm.txt +++ b/llm.txt @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -433,7 +433,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/open-sse/utils/providerRequestLogging.ts b/open-sse/utils/providerRequestLogging.ts index 5b10726fb5..c349f8da75 100644 --- a/open-sse/utils/providerRequestLogging.ts +++ b/open-sse/utils/providerRequestLogging.ts @@ -127,9 +127,56 @@ export function captureCurrentProviderBody( return captureCurrentProviderRequest(url, headers, parseBody(bodyString), bodyString, log); } +const DISPATCH_STATE_KEY = Symbol.for("omniroute.providerRequestCapture.dispatch"); +const dispatchContext = (( + globalThis as typeof globalThis & { + [DISPATCH_STATE_KEY]?: AsyncLocalStorage<{ settled: boolean }>; + } +)[DISPATCH_STATE_KEY] ??= new AsyncLocalStorage<{ settled: boolean }>()); + +type DispatchStartListener = () => void; + +const DISPATCH_LISTENERS_KEY = Symbol.for( + "omniroute.providerRequestCapture.dispatchStartListeners" +); + +function getDispatchStartListeners(): Set { + const scopedGlobal = globalThis as typeof globalThis & { + [DISPATCH_LISTENERS_KEY]?: Set; + }; + return (scopedGlobal[DISPATCH_LISTENERS_KEY] ??= new Set()); +} + +/** + * Run listeners at the entry of every provider dispatch, before fn starts. A + * listener resolves what it needs through its own closure (usually an ALS + * reader), so a dispatch for another request never touches this request's + * state. Listeners must stay synchronous and side-effect free beyond the + * request's own sink. + */ +export function onDispatchStart(listener: DispatchStartListener): void { + getDispatchStartListeners().add(listener); +} + export function runWithCapture(requestCapture: Capture, fn: () => Promise): Promise { installFetchCapture(); - return captureState.context.run(requestCapture, fn); + const dispatch = { settled: false }; + return dispatchContext.run(dispatch, () => { + for (const listener of getDispatchStartListeners()) listener(); + return captureState.context.run(requestCapture, fn).finally(() => { + dispatch.settled = true; + }); + }); +} + +/** + * True while a provider request is being dispatched: inside runWithCapture and before its + * fn settles. A fetch the executor leaves running after it returns (a background + * bookkeeping call) keeps the async context but no longer counts as the dispatch. + */ +export function isProviderRequestCaptureActive(): boolean { + const dispatch = dispatchContext.getStore(); + return dispatch !== undefined && !dispatch.settled; } function installFetchCapture() { diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 349bb71a2d..f060573f98 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -14,6 +14,7 @@ import { proxyUrlForLogs, } from "./proxyDispatcher.ts"; import tlsClient, { type TlsFetchOptions, guardTlsFirstByte } from "./tlsClient.ts"; +import { withUpstreamStatusCapture } from "./upstreamStatusCapture.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; import { isControlPlaneProxyDirectFallbackEnabled, @@ -188,7 +189,7 @@ type TlsFingerprintStore = { * the egress logger read the innermost applied proxy (the last writer wins, which * is the executor's per-account proxy). */ -export type AppliedProxySink = { proxy: unknown }; +export type AppliedProxySink = { proxy: unknown; upstreamStatus?: number }; const appliedProxyContext = new AsyncLocalStorage(); /** @@ -753,7 +754,7 @@ export async function runWithProxyContextOrDirect(proxyConfig, fn) { return runWithProxyContext(proxyConfig, fn, { directFallbackOnUnreachable: true }); } -async function patchedFetch( +async function patchedFetchUnrecorded( input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}, deps: ProxyFetchDeps = {} @@ -1179,6 +1180,9 @@ async function patchedFetch( throw lastProxyError; } +const getAppliedProxySink = () => appliedProxyContext.getStore(); +const patchedFetch = withUpstreamStatusCapture(patchedFetchUnrecorded, getAppliedProxySink); + /** * Named export for proxyFetch — identical to the patched globalThis.fetch but * accepts an optional ProxyFetchDeps for unit test dependency injection. diff --git a/open-sse/utils/upstreamStatusCapture.ts b/open-sse/utils/upstreamStatusCapture.ts new file mode 100644 index 0000000000..6f336ff15d --- /dev/null +++ b/open-sse/utils/upstreamStatusCapture.ts @@ -0,0 +1,43 @@ +import { isProviderRequestCaptureActive, onDispatchStart } from "./providerRequestLogging.ts"; + +/** + * Wrap the process-wide fetch so the HTTP status the provider actually returned lands on + * the request's applied-proxy sink. Only calls that settle while a provider request is + * being dispatched count: side fetches of the same request (usage sync, dashboard events) + * and background calls an executor leaves running after it returns must not overwrite the + * provider's status. A new dispatch invalidates the earlier status at entry, so a retry + * that never reaches the network (local refusal, start timeout) leaves nothing stale + * behind; within the dispatch the last response received wins, which follows an + * executor's own retries. A background call keeps a settled dispatch token and never + * writes, so clearing at entry cannot race with it. A call that throws clears the + * status, so a network error on one proxy after a 429 on another leaves no stale 429 + * behind. The response and any exception pass through. In cloud mode the default export + * is the unpatched fetch, so nothing is captured and the log keeps null. + * + * `isDispatching` is injectable for tests only. + */ +export function withUpstreamStatusCapture( + inner: (...args: A) => Promise, + getSink: () => { upstreamStatus?: number } | undefined, + isDispatching: () => boolean = isProviderRequestCaptureActive +): (...args: A) => Promise { + if (isDispatching === isProviderRequestCaptureActive) { + onDispatchStart(() => { + const sink = getSink(); + if (sink) delete sink.upstreamStatus; + }); + } + return async (...args: A) => { + const sink = isDispatching() ? getSink() : undefined; + if (!sink) return inner(...args); + let response: Response; + try { + response = await inner(...args); + } catch (error) { + if (isDispatching()) sink.upstreamStatus = undefined; + throw error; + } + if (isDispatching()) sink.upstreamStatus = response.status; + return response; + }; +} diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 58073518b8..6c38f6cc37 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -593,6 +593,11 @@ function isSchemaAlreadyApplied( hasColumn(db, "provider_nodes", "daily_quota_reset_timezone") && hasColumn(db, "provider_nodes", "daily_quota_reset_hour") ); + case "179": + // proxy_logs.upstream_status may already exist if ensureProxyLogsColumns ran first; + // a bare ADD COLUMN would then throw. Renumbering the migration means renaming this case + // (keyed by version only: a stale "177" here would skip 177_provider_connection_synced_models_at). + return hasColumn(db, "proxy_logs", "upstream_status"); default: return false; } diff --git a/src/lib/db/migrations/179_proxy_logs_upstream_status.sql b/src/lib/db/migrations/179_proxy_logs_upstream_status.sql new file mode 100644 index 0000000000..15318312ef --- /dev/null +++ b/src/lib/db/migrations/179_proxy_logs_upstream_status.sql @@ -0,0 +1,4 @@ +-- upstream_status: HTTP status the provider actually returned for the logged request. +-- NULL when no response was received (network error, local refusal). No index: not a +-- query dimension. +ALTER TABLE proxy_logs ADD COLUMN upstream_status INTEGER; diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index d0d7b42493..f422d64270 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -292,6 +292,10 @@ export function ensureProxyLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE proxy_logs ADD COLUMN egress_ip TEXT"); console.log("[DB] Added proxy_logs.egress_ip column"); } + if (!columnNames.has("upstream_status")) { + db.exec("ALTER TABLE proxy_logs ADD COLUMN upstream_status INTEGER"); + console.log("[DB] Added proxy_logs.upstream_status column"); + } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify proxy_logs schema:", message); diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 0bb278aa0f..21786c839e 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -41,6 +41,8 @@ interface ProxyLogEntry { comboId: string | null; account: string | null; tlsFingerprint: boolean; + /** HTTP status the provider actually returned; null when no response was received. */ + upstreamStatus: number | null; } type ProxyLogInput = Partial & { @@ -93,6 +95,7 @@ function loadFromDb() { comboId: row.combo_id || null, account: row.account || null, tlsFingerprint: row.tls_fingerprint === 1, + upstreamStatus: typeof row.upstream_status === "number" ? row.upstream_status : null, }); } @@ -174,6 +177,7 @@ export function logProxyEvent(entry: ProxyLogInput) { comboId: entry.comboId || null, account: entry.account || null, tlsFingerprint: entry.tlsFingerprint || false, + upstreamStatus: entry.upstreamStatus ?? null, }; // Structured egress line so the operator can confirm, in the proxy logs, which @@ -263,10 +267,10 @@ export function flushProxyLogsSync() { const insertStmt = db.prepare( `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, level, level_id, provider, target_url, public_ip, egress_ip, latency_ms, error, - connection_id, combo_id, account, tls_fingerprint) + connection_id, combo_id, account, tls_fingerprint, upstream_status) VALUES (@id, @timestamp, @status, @proxyType, @proxyHost, @proxyPort, @level, @levelId, @provider, @targetUrl, @clientIp, @egressIp, @latencyMs, @error, - @connectionId, @comboId, @account, @tlsFingerprint)` + @connectionId, @comboId, @account, @tlsFingerprint, @upstreamStatus)` ); const transaction = db.transaction((entries: ProxyLogEntry[]) => { @@ -290,6 +294,7 @@ export function flushProxyLogsSync() { comboId: item.comboId, account: item.account, tlsFingerprint: item.tlsFingerprint ? 1 : 0, + upstreamStatus: item.upstreamStatus, }); } }); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 4ab6826fd6..3613e05071 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -98,7 +98,7 @@ import { handleNoCredentials, safeResolveProxy, safeLogEvents, - applyExecutorProxyToInfo, + mergeAppliedProxySink, shouldRetryStreamEarlyEof, isEarlyEofSiblingFailoverOn, withSessionHeader, @@ -1929,7 +1929,7 @@ async function handleSingleModelChat( } // #5217: sink for the proxy the executor pins internally (e.g. OpencodeExecutor // rotation) so the egress log below reflects the real egress, not "direct". - const appliedProxySink: { proxy: unknown } = { proxy: null }; + const appliedProxySink: { proxy: unknown; upstreamStatus?: number } = { proxy: null }; const proxyStartTime = Date.now(); // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); @@ -2000,7 +2000,7 @@ async function handleSingleModelChat( // #5217: reflect the proxy the executor actually applied (per-account rotation). void safeLogEvents({ result, - proxyInfo: applyExecutorProxyToInfo(proxyInfo, appliedProxySink.proxy), + proxyInfo: mergeAppliedProxySink(proxyInfo, appliedProxySink), proxyLatency, provider, model, diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 9632236691..bf3d485460 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -1006,6 +1006,27 @@ export function applyExecutorProxyToInfo( }; } +/** + * Carry the HTTP status the provider actually returned (captured on the applied-proxy + * sink around the patched fetch) into proxyInfo. Nothing received -> info unchanged. + * Pure + unit-testable. + */ +export function withUpstreamStatus( + info: T | null | undefined, + sink: { upstreamStatus?: number } +) { + if (typeof sink.upstreamStatus !== "number") return info; + return { ...(info || {}), upstreamStatus: sink.upstreamStatus }; +} + +/** Merge both things the applied-proxy sink captured: the executor proxy, then the status. */ +export function mergeAppliedProxySink( + proxyInfo: { proxy?: unknown; level?: string; levelId?: string | null } | null | undefined, + sink: { proxy: unknown; upstreamStatus?: number } +) { + return withUpstreamStatus(applyExecutorProxyToInfo(proxyInfo, sink.proxy), sink); +} + // Async because the egress-IP lookup lazy-imports proxyEgress; callers treat // this as fire-and-forget logging (the internal try/catch swallows everything). export async function safeLogEvents({ @@ -1063,6 +1084,7 @@ export async function safeLogEvents({ comboId: comboName || null, account: credentials.connectionId?.slice(0, 8) || null, tlsFingerprint: tlsFingerprintUsed, + upstreamStatus: proxyInfo?.upstreamStatus ?? null, }); } catch {} diff --git a/tests/unit/merge-applied-proxy-sink.test.ts b/tests/unit/merge-applied-proxy-sink.test.ts new file mode 100644 index 0000000000..c8d4a50fe0 --- /dev/null +++ b/tests/unit/merge-applied-proxy-sink.test.ts @@ -0,0 +1,113 @@ +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"; + +// The received status travels with the applied proxy: one merge at the log call site, and +// safeLogEvents forwards it to the proxy log. A locally generated 429 (no fetch) has no +// upstream status. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-merge-applied-sink-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); +const { withUpstreamStatus, mergeAppliedProxySink, safeLogEvents } = + await import("../../src/sse/handlers/chatHelpers.ts"); + +test.beforeEach(() => { + proxyLogger.clearProxyLogs(); +}); + +test.after(() => { + proxyLogger.clearProxyLogs(); + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const PROXY_B = { type: "http", host: "127.0.0.1", port: 18080 }; + +test("withUpstreamStatus leaves the info untouched when nothing was received", () => { + const info = { proxy: null, level: "direct", levelId: null }; + assert.strictEqual(withUpstreamStatus(info, {}), info); + assert.strictEqual(withUpstreamStatus(null, {}), null); +}); + +test("withUpstreamStatus adds the received status without touching other fields", () => { + const info = { proxy: PROXY_B, level: "account", levelId: "c1" }; + assert.deepEqual(withUpstreamStatus(info, { upstreamStatus: 429 }), { + ...info, + upstreamStatus: 429, + }); + assert.deepEqual(withUpstreamStatus(null, { upstreamStatus: 502 }), { upstreamStatus: 502 }); +}); + +test("mergeAppliedProxySink applies the executor proxy and the status together", () => { + const merged = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null }, + { proxy: PROXY_B, upstreamStatus: 429 } + ); + assert.deepEqual(merged, { + proxy: PROXY_B, + level: "account", + levelId: null, + upstreamStatus: 429, + }); +}); + +test("mergeAppliedProxySink after a network error on proxy B keeps B and no status", () => { + const merged = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null }, + { proxy: PROXY_B, upstreamStatus: undefined } + ); + assert.deepEqual(merged, { proxy: PROXY_B, level: "account", levelId: null }); +}); + +test("the captured status wins over an upstreamStatus already on proxyInfo", () => { + const merged = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null, upstreamStatus: 111 } as { + proxy: unknown; + level: string; + levelId: string | null; + }, + { proxy: null, upstreamStatus: 429 } + ); + assert.equal((merged as { upstreamStatus?: number }).upstreamStatus, 429); +}); + +test("mergeAppliedProxySink with an empty sink returns the original proxyInfo", () => { + const proxyInfo = { proxy: null, level: "direct", levelId: null }; + assert.strictEqual(mergeAppliedProxySink(proxyInfo, { proxy: null }), proxyInfo); +}); + +function logArgs(proxyInfo: unknown, status: number) { + return { + result: { success: false, status, error: "rate limited" }, + proxyInfo, + proxyLatency: 5, + provider: "openai", + model: "gpt-5", + sourceFormat: "openai", + targetFormat: "openai", + credentials: { connectionId: "conn-12345678" }, + comboName: null, + clientRawRequest: null, + }; +} + +test("safeLogEvents forwards the received status to the proxy log", async () => { + const proxyInfo = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null }, + { proxy: PROXY_B, upstreamStatus: 429 } + ); + await safeLogEvents(logArgs(proxyInfo, 429)); + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, 429); + assert.equal(entry.status, "error"); +}); + +test("a local 429 with no fetch logs a null upstream status", async () => { + await safeLogEvents(logArgs({ proxy: PROXY_B, level: "provider", levelId: "openai" }, 429)); + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, null); +}); diff --git a/tests/unit/migration-179-proxy-logs-upstream-status.test.ts b/tests/unit/migration-179-proxy-logs-upstream-status.test.ts new file mode 100644 index 0000000000..2136fb44b5 --- /dev/null +++ b/tests/unit/migration-179-proxy-logs-upstream-status.test.ts @@ -0,0 +1,81 @@ +// proxy_logs.upstream_status ships as migration 179. Its idempotency check lives in +// migrationRunner's version-keyed switch, so the number there must match the file: under the +// colliding "177" key the check would answer for 177_provider_connection_synced_models_at and +// skip that unrelated migration on any database whose proxy_logs already had the column +// (ensureProxyLogsColumns adds it at boot). Runs the real runner on the real SQL files. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const repoMigrations = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/lib/db/migrations" +); +const migrationsDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-179-")); +for (const file of [ + "177_provider_connection_synced_models_at.sql", + "179_proxy_logs_upstream_status.sql", +]) { + fs.copyFileSync(path.join(repoMigrations, file), path.join(migrationsDir, file)); +} +const originalMigrationsDir = process.env.OMNIROUTE_MIGRATIONS_DIR; +process.env.OMNIROUTE_MIGRATIONS_DIR = migrationsDir; + +const { runMigrations } = await import("../../src/lib/db/migrationRunner.ts"); + +test.after(() => { + fs.rmSync(migrationsDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (originalMigrationsDir === undefined) delete process.env.OMNIROUTE_MIGRATIONS_DIR; + else process.env.OMNIROUTE_MIGRATIONS_DIR = originalMigrationsDir; +}); + +function columns(db: Database.Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map( + (c) => c.name + ); +} + +function ledger(db: Database.Database) { + return db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(); +} + +function legacyDb(withUpstreamStatus: boolean): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE provider_connections (id TEXT PRIMARY KEY); + CREATE TABLE proxy_logs (id TEXT PRIMARY KEY${withUpstreamStatus ? ", upstream_status INTEGER" : ""}); + `); + return db; +} + +test("proxy_logs.upstream_status already added at boot: 177 still runs, 179 is skipped", () => { + const db = legacyDb(true); + try { + runMigrations(db, { isNewDb: true }); + assert.ok( + columns(db, "provider_connections").includes("synced_models_at"), + "177_provider_connection_synced_models_at must not be skipped by the 179 idempotency check" + ); + assert.deepEqual(ledger(db), [ + { version: "177", name: "provider_connection_synced_models_at" }, + { version: "179", name: "proxy_logs_upstream_status" }, + ]); + } finally { + db.close(); + } +}); + +test("a database without the column gets it from migration 179", () => { + const db = legacyDb(false); + try { + runMigrations(db, { isNewDb: true }); + assert.ok(columns(db, "proxy_logs").includes("upstream_status")); + assert.ok(columns(db, "provider_connections").includes("synced_models_at")); + } finally { + db.close(); + } +}); diff --git a/tests/unit/proxy-logs-upstream-status.test.ts b/tests/unit/proxy-logs-upstream-status.test.ts new file mode 100644 index 0000000000..ab5a14dd6c --- /dev/null +++ b/tests/unit/proxy-logs-upstream-status.test.ts @@ -0,0 +1,86 @@ +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"; + +// proxy_logs keeps the HTTP status the provider actually returned, next to the textual +// status column. Persistence only runs in local mode (!isCloud && !isBuildPhase), which a +// fresh DATA_DIR keeps. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-upstream-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); + +function resetStorage() { + proxyLogger.clearProxyLogs(); + core.closeDbInstance(); + 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.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("a fresh install has upstream_status and the reconciler restores it", async () => { + const { ensureProxyLogsColumns, hasColumn } = await import("../../src/lib/db/schemaColumns.ts"); + const db = core.getDbInstance(); + assert.equal(hasColumn(db, "proxy_logs", "upstream_status"), true, "column after migration"); + db.exec("ALTER TABLE proxy_logs DROP COLUMN upstream_status"); + assert.equal(hasColumn(db, "proxy_logs", "upstream_status"), false); + ensureProxyLogsColumns(db); + assert.equal(hasColumn(db, "proxy_logs", "upstream_status"), true, "column restored"); + assert.doesNotThrow(() => ensureProxyLogsColumns(db)); +}); + +test("upstreamStatus is kept in memory, in SQLite and in the export", async () => { + proxyLogger.logProxyEvent({ + status: "error", + provider: "openai", + targetUrl: "openai/gpt-5", + upstreamStatus: 429, + }); + + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, 429); + + proxyLogger.flushProxyLogsSync(); + const row = core.getDbInstance().prepare("SELECT upstream_status FROM proxy_logs").get() as { + upstream_status: number | null; + }; + assert.equal(row.upstream_status, 429); + + const { exportProxyLogsSince } = await import("../../src/lib/db/proxyLogs.ts"); + const [exported] = exportProxyLogsSince("1970-01-01T00:00:00.000Z"); + assert.equal(exported.upstream_status, 429); +}); + +test("an entry without upstreamStatus stores null, never 0", () => { + proxyLogger.logProxyEvent({ status: "error", provider: "openai", targetUrl: "openai/gpt-5" }); + + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, null); + + proxyLogger.flushProxyLogsSync(); + const row = core.getDbInstance().prepare("SELECT upstream_status FROM proxy_logs").get() as { + upstream_status: number | null; + }; + assert.equal(row.upstream_status, null); +}); + +test("the textual status filters return the same rows as before", () => { + proxyLogger.logProxyEvent({ status: "success", provider: "openai", upstreamStatus: 200 }); + proxyLogger.logProxyEvent({ status: "error", provider: "openai", upstreamStatus: 429 }); + proxyLogger.logProxyEvent({ status: "error", provider: "openai" }); + + assert.equal(proxyLogger.getProxyLogs({ status: "ok" }).length, 1); + assert.equal(proxyLogger.getProxyLogs({ status: "error" }).length, 2); + assert.equal(proxyLogger.getProxyLogs({ status: "timeout" }).length, 0); +}); diff --git a/tests/unit/proxyfetch-upstream-status-capture.test.ts b/tests/unit/proxyfetch-upstream-status-capture.test.ts new file mode 100644 index 0000000000..47e2eb191a --- /dev/null +++ b/tests/unit/proxyfetch-upstream-status-capture.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +// End to end through the real patched fetch: the status of a provider dispatch reaches +// the applied-proxy sink, whichever fetch path serves it, while a side fetch made later in +// the same request does not overwrite it. + +const proxyFetchModule = await import("../../open-sse/utils/proxyFetch.ts"); +const { runWithCapture } = await import("../../open-sse/utils/providerRequestLogging.ts"); + +const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; +let server: http.Server; +let baseUrl = ""; + +test.before(async () => { + server = http.createServer((req, res) => { + const params = new URL(req.url ?? "/", "http://local").searchParams; + setTimeout( + () => { + res.writeHead(Number(params.get("code") ?? 200), { "content-type": "application/json" }); + res.end("{}"); + }, + Number(params.get("delay") ?? 0) + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +test.after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +type Sink = { proxy: unknown; upstreamStatus?: number }; + +function inRequest(sink: Sink, fn: () => Promise) { + return proxyFetchModule.runWithAppliedProxyCapture(sink, fn); +} + +async function dispatch(fn: () => Promise) { + await runWithCapture(capture, async () => { + const res = await fn(); + await res.text(); + }); +} + +test("a provider dispatch records the received status", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, () => dispatch(() => fetch(`${baseUrl}/v1?code=429`))); + assert.equal(sink.upstreamStatus, 429); +}); + +test("the last response received during the dispatch wins", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, () => + dispatch(async () => { + await (await fetch(`${baseUrl}/v1?code=200`)).text(); + return fetch(`${baseUrl}/v1?code=500`); + }) + ); + assert.equal(sink.upstreamStatus, 500); +}); + +test("a fetch still running when the dispatch returns does not change the status", async () => { + const sink: Sink = { proxy: null }; + let background: Promise = Promise.resolve(); + await inRequest(sink, async () => { + await dispatch(async () => { + const res = await fetch(`${baseUrl}/v1?code=429`); + background = Promise.all([ + fetch(`${baseUrl}/finish?code=200&delay=50`).then((r) => r.text()), + fetch("http://127.0.0.1:1/finish").catch(() => null), + ]); + return res; + }); + await background; + }); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a side fetch after the dispatch keeps the provider status", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, async () => { + await dispatch(() => fetch(`${baseUrl}/v1?code=429`)); + const side = await fetch(`${baseUrl}/usage?code=500`); + await side.text(); + }); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a second dispatch without any fetch leaves nothing stale behind", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, async () => { + await dispatch(() => fetch(`${baseUrl}/v1?code=429`)); + assert.equal(sink.upstreamStatus, 429); + await runWithCapture(capture, async () => {}); + }); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("a network error on the next dispatch clears the earlier status", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, async () => { + await dispatch(() => fetch(`${baseUrl}/v1?code=429`)); + await assert.rejects(dispatch(() => fetch("http://127.0.0.1:1/v1"))); + }); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("the explicit-dispatcher path is captured", async () => { + const sink: Sink = { proxy: null }; + const undiciFetch = async () => new Response(null, { status: 502 }); + await inRequest(sink, () => + dispatch(() => + proxyFetchModule.proxyFetch(`${baseUrl}/v1`, { dispatcher: {} } as RequestInit, { + undiciFetch, + }) + ) + ); + assert.equal(sink.upstreamStatus, 502); +}); + +test("the explicit direct-context path is captured", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, () => + dispatch(() => + proxyFetchModule.runWithDirectFetchContext(() => fetch(`${baseUrl}/v1?code=403`)) + ) + ); + assert.equal(sink.upstreamStatus, 403); +}); + +test("a dispatch without any sink in scope still works", async () => { + let status = 0; + await runWithCapture(capture, async () => { + const res = await fetch(`${baseUrl}/v1?code=200`); + status = res.status; + await res.text(); + }); + assert.equal(status, 200); +}); diff --git a/tests/unit/upstream-status-capture.test.ts b/tests/unit/upstream-status-capture.test.ts new file mode 100644 index 0000000000..fa96fdd472 --- /dev/null +++ b/tests/unit/upstream-status-capture.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// The upstream status is captured once, around the process-wide fetch, and only while a +// provider request is being dispatched. These tests pin the wrapper on its own. + +const { withUpstreamStatusCapture } = await import("../../open-sse/utils/upstreamStatusCapture.ts"); +const { isProviderRequestCaptureActive, runWithCapture } = + await import("../../open-sse/utils/providerRequestLogging.ts"); + +type Sink = { upstreamStatus?: number }; + +const dispatchCapture = { capture: () => {}, body: (fallback: unknown) => fallback }; + +function respond(status: number) { + return async () => new Response(null, { status }); +} + +test("records the status of a dispatched call and returns the response untouched", async () => { + const sink: Sink = {}; + const response = new Response(null, { status: 429 }); + const wrapped = withUpstreamStatusCapture( + async () => response, + () => sink, + () => true + ); + assert.equal(await wrapped(), response); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a call outside a dispatch leaves the sink alone", async () => { + const sink: Sink = { upstreamStatus: 429 }; + const wrapped = withUpstreamStatusCapture( + respond(200), + () => sink, + () => false + ); + await wrapped(); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a dispatched call that throws clears the earlier status", async () => { + const sink: Sink = {}; + const first = withUpstreamStatusCapture( + respond(429), + () => sink, + () => true + ); + await first(); + assert.equal(sink.upstreamStatus, 429); + + const failing = withUpstreamStatusCapture( + async () => { + throw new TypeError("fetch failed"); + }, + () => sink, + () => true + ); + await assert.rejects(failing(), /fetch failed/); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("a response that arrives after the dispatch ended leaves the sink alone", async () => { + const sink: Sink = { upstreamStatus: 429 }; + let dispatching = true; + const wrapped = withUpstreamStatusCapture( + async () => { + dispatching = false; + return new Response(null, { status: 500 }); + }, + () => sink, + () => dispatching + ); + await wrapped(); + assert.equal(sink.upstreamStatus, 429); +}); + +test("forwards every argument to the inner fetch", async () => { + const seen: unknown[][] = []; + const inner = async (...args: unknown[]) => { + seen.push(args); + return new Response(null, { status: 204 }); + }; + const wrapped = withUpstreamStatusCapture( + inner, + () => undefined, + () => true + ); + const deps = { marker: true }; + await wrapped("http://example.test", { method: "GET" }, deps); + assert.deepEqual(seen, [["http://example.test", { method: "GET" }, deps]]); +}); + +test("no sink in scope is harmless", async () => { + const wrapped = withUpstreamStatusCapture( + respond(500), + () => undefined, + () => true + ); + assert.equal((await wrapped()).status, 500); +}); + +test("a second dispatch without any fetch leaves nothing stale behind", async () => { + const sink: Sink = {}; + const wrapped = withUpstreamStatusCapture(respond(429), () => sink); + await runWithCapture(dispatchCapture, async () => { + await wrapped(); + }); + assert.equal(sink.upstreamStatus, 429); + await runWithCapture(dispatchCapture, async () => {}); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("isProviderRequestCaptureActive is true only until runWithCapture settles", async () => { + assert.equal(isProviderRequestCaptureActive(), false); + const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let later: Promise = Promise.resolve(true); + const inside = await runWithCapture(capture, async () => { + later = gate.then(() => isProviderRequestCaptureActive()); + return isProviderRequestCaptureActive(); + }); + assert.equal(inside, true); + release(); + assert.equal(await later, false, "a continuation started inside sees the dispatch ended"); + assert.equal(isProviderRequestCaptureActive(), false); +}); From 692233295904ae32b953ec43f7eef97b17b1ccf4 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:45:54 +0200 Subject: [PATCH 27/36] feat(proxies): stop re-serving a pool member the provider just refused (#13602) Behind `PROXY_SKIP_RECENTLY_FAILED` (from #13578): a provider 429 received through a pool member sets that member aside and a 2xx clears it, for opencode providers. Maintainer rework before merge (kept the idea, no default behavior change): - `noteProxyOutcome` ran inside the fire-and-forget `safeLogEvents` after awaited dynamic imports, so a concurrent request could still pick the member; it now runs first, synchronously, before any `await`. - The duplicate `177_proxy_logs_upstream_status.sql` the stack still carried alongside the renamed 179 was removed; the regression test the PR body named exists as `pool-ip-quota-429-path.test.ts`. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13602-pool-skips-refused-member.md | 1 + config/quality/file-size-baseline.json | 3 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/az.json | 1 + src/i18n/messages/bg.json | 1 + src/i18n/messages/bn.json | 1 + src/i18n/messages/cs.json | 1 + src/i18n/messages/da.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/el.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/et.json | 1 + src/i18n/messages/fa.json | 1 + src/i18n/messages/fi.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ga.json | 1 + src/i18n/messages/gu.json | 1 + src/i18n/messages/he.json | 1 + src/i18n/messages/hi.json | 1 + src/i18n/messages/hr.json | 1 + src/i18n/messages/hu.json | 1 + src/i18n/messages/id.json | 1 + src/i18n/messages/it.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/km.json | 1 + src/i18n/messages/kn.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/lt.json | 1 + src/i18n/messages/lv.json | 1 + src/i18n/messages/ml.json | 1 + src/i18n/messages/mr.json | 1 + src/i18n/messages/ms.json | 1 + src/i18n/messages/mt.json | 1 + src/i18n/messages/my.json | 1 + src/i18n/messages/ne.json | 1 + src/i18n/messages/nl.json | 1 + src/i18n/messages/no.json | 1 + src/i18n/messages/or.json | 1 + src/i18n/messages/pa.json | 1 + src/i18n/messages/phi.json | 1 + src/i18n/messages/pl.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/ro.json | 1 + src/i18n/messages/ru.json | 1 + src/i18n/messages/si.json | 1 + src/i18n/messages/sk.json | 1 + src/i18n/messages/sl.json | 1 + src/i18n/messages/sr.json | 1 + src/i18n/messages/sv.json | 1 + src/i18n/messages/sw.json | 1 + src/i18n/messages/ta.json | 1 + src/i18n/messages/te.json | 1 + src/i18n/messages/th.json | 1 + src/i18n/messages/tr.json | 1 + src/i18n/messages/uk-UA.json | 1 + src/i18n/messages/ur.json | 1 + src/i18n/messages/vi.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/sse/handlers/chatHelpers.ts | 12 ++ src/sse/handlers/proxyOutcomeMemory.ts | 42 +++++ tests/unit/pool-ip-quota-429-path.test.ts | 152 ++++++++++++++++++ tests/unit/proxy-outcome-memory.test.ts | 100 ++++++++++++ 65 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/13602-pool-skips-refused-member.md create mode 100644 src/sse/handlers/proxyOutcomeMemory.ts create mode 100644 tests/unit/pool-ip-quota-429-path.test.ts create mode 100644 tests/unit/proxy-outcome-memory.test.ts diff --git a/changelog.d/features/13602-pool-skips-refused-member.md b/changelog.d/features/13602-pool-skips-refused-member.md new file mode 100644 index 0000000000..2c96150e63 --- /dev/null +++ b/changelog.d/features/13602-pool-skips-refused-member.md @@ -0,0 +1 @@ +- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b2f30f413a..5cfee5c354 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_15_13602_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1214. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13580_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1202. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_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.", @@ -342,7 +343,7 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", "frozen": { - "src/sse/handlers/chatHelpers.ts": 1202, + "src/sse/handlers/chatHelpers.ts": 1214, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, = 300 || !hasProxyRefusals()) return; + const key = proxyEgressKey(proxyInfo?.proxy); + if (inRefusalScope) noteProxyServed(key); + else noteProxyRecovered(key, "proxy_unreachable"); +} diff --git a/tests/unit/pool-ip-quota-429-path.test.ts b/tests/unit/pool-ip-quota-429-path.test.ts new file mode 100644 index 0000000000..80b5e38223 --- /dev/null +++ b/tests/unit/pool-ip-quota-429-path.test.ts @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; + +// Through the real pieces of the chat path: a provider dispatch refused through a pool +// member, the capture on the applied-proxy sink, the merge at the log call site and +// safeLogEvents. With PROXY_SKIP_RECENTLY_FAILED on, the next pick of that pool skips the +// member (already before the fire-and-forget log settles); a locally generated failure does +// not; a provider outside the refusal scope does not. With the flag off nothing changes. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-refused-path-")); +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 proxyLogger = await import("../../src/lib/proxyLogger.ts"); +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); +const proxyFetchModule = await import("../../open-sse/utils/proxyFetch.ts"); +const { runWithCapture } = await import("../../open-sse/utils/providerRequestLogging.ts"); +const { mergeAppliedProxySink, safeLogEvents } = + await import("../../src/sse/handlers/chatHelpers.ts"); + +const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; +let server: http.Server; +let baseUrl = ""; + +test.before(async () => { + server = http.createServer((req, res) => { + const code = Number(new URL(req.url ?? "/", "http://local").searchParams.get("code") ?? 200); + res.writeHead(code, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +test.after(async () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + await new Promise((resolve) => server.close(() => resolve())); + proxyLogger.clearProxyLogs(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; + proxyLogger.clearProxyLogs(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +async function twoMemberPool() { + const members = []; + for (const port of [9301, 9302]) { + const proxy = await proxiesDb.createProxy({ + name: `m${port}`, + type: "http", + host: "10.4.1.1", + port, + }); + await proxiesDb.addProxyToScopePool("provider", "opencode", proxy.id); + members.push({ type: "http", host: "10.4.1.1", port }); + } + return members; +} + +async function pickPort() { + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry("provider", "opencode"); + return (resolved as { proxy: { port: number } }).proxy.port; +} + +// One request as chat.ts runs it: the executor pins a proxy on the sink, the provider +// dispatch goes through the patched fetch, then the log call merges the sink. +async function chatRequest( + provider: string, + pinned: unknown, + code: number | null, + onLogCalled: () => void = () => {} +) { + const sink: { proxy: unknown; upstreamStatus?: number } = { proxy: null }; + await proxyFetchModule.runWithAppliedProxyCapture(sink, async () => { + sink.proxy = pinned; + if (code !== null) { + await runWithCapture(capture, async () => { + const res = await fetch(`${baseUrl}/v1/chat/completions?code=${code}`); + await res.text(); + }); + } + }); + // chat.ts fires this without awaiting it. + const logged = safeLogEvents({ + result: { success: code === 200, status: code ?? 429, error: code === 200 ? null : "failed" }, + proxyInfo: mergeAppliedProxySink({ proxy: null, level: "provider", levelId: provider }, sink), + proxyLatency: 1, + provider, + model: "m", + sourceFormat: "openai", + targetFormat: "openai", + credentials: { connectionId: "conn-refused-path" }, + comboName: null, + clientRawRequest: null, + }); + onLogCalled(); + await logged; +} + +test("a received refusal through a pool member makes the next pick skip it", async () => { + const [first, second] = await twoMemberPool(); + assert.equal(await pickPort(), first.port); + await chatRequest("opencode", first, 429); + assert.equal(proxyLogger.getProxyLogs()[0].upstreamStatus, 429); + assert.deepEqual([await pickPort(), await pickPort()], [second.port, second.port]); +}); + +test("the member is set aside as soon as the log call returns, not when the log settles", async () => { + const [first, second] = await twoMemberPool(); + let avoidedAtCallSite: boolean | null = null; + await chatRequest("opencode", first, 429, () => { + avoidedAtCallSite = memory.isProxyAvoided(memory.proxyEgressKey(first)); + }); + assert.equal(avoidedAtCallSite, true, "a concurrent pick must already skip the refused member"); + assert.deepEqual([await pickPort(), await pickPort()], [second.port, second.port]); +}); + +test("with the flag at its default (off) a received refusal leaves the member in rotation", async () => { + const [first, second] = await twoMemberPool(); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + await chatRequest("opencode", first, 429); + assert.equal(proxyLogger.getProxyLogs()[0].upstreamStatus, 429); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); + assert.deepEqual([await pickPort(), await pickPort()], [first.port, second.port]); +}); + +test("a locally generated failure (no dispatch) leaves the member in rotation", async () => { + const [first, second] = await twoMemberPool(); + await chatRequest("opencode", first, null); + assert.equal(proxyLogger.getProxyLogs()[0].upstreamStatus, null); + assert.deepEqual([await pickPort(), await pickPort()], [first.port, second.port]); +}); + +test("a refusal for a provider outside the scope leaves the member in rotation", async () => { + const [first, second] = await twoMemberPool(); + await chatRequest("opencode-zen", first, 429); + assert.deepEqual([await pickPort(), await pickPort()], [first.port, second.port]); +}); diff --git a/tests/unit/proxy-outcome-memory.test.ts b/tests/unit/proxy-outcome-memory.test.ts new file mode 100644 index 0000000000..67ea235c92 --- /dev/null +++ b/tests/unit/proxy-outcome-memory.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// With PROXY_SKIP_RECENTLY_FAILED on, a pool member is set aside only on a refusal the +// provider really returned through it, and only for a provider inside the refusal scope. +// Locally generated failures carry no upstream outcome and never set a member aside. With +// the flag off (the default) nothing is ever written. + +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); +const { noteProxyOutcome } = await import("../../src/sse/handlers/proxyOutcomeMemory.ts"); +const { egressBucketedLockProviders } = await import("../../open-sse/config/providerErrorRules.ts"); + +const PROXY = { type: "http", host: "10.4.0.1", port: 8080 }; +const KEY = memory.proxyEgressKey(PROXY); + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; +}); + +test.after(() => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; +}); + +test("a received refusal from an in-scope provider sets the member aside", () => { + for (const provider of [...egressBucketedLockProviders(), "OpenCode"]) { + memory.__resetProxyRefusalMemoryForTesting(); + noteProxyOutcome(provider, { proxy: PROXY, upstreamStatus: 429 }); + assert.equal(memory.isProxyAvoided(KEY), true, provider); + } +}); + +test("a refusal from a provider outside the scope writes nothing", () => { + for (const provider of ["opencode-zen", "openai", null]) { + noteProxyOutcome(provider, { proxy: PROXY, upstreamStatus: 429 }); + } + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("no received status, no proxy or no proxyInfo writes nothing", () => { + noteProxyOutcome("opencode", { proxy: PROXY }); + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: null }); + noteProxyOutcome("opencode", { proxy: null, upstreamStatus: 429 }); + noteProxyOutcome("opencode", null); + noteProxyOutcome("opencode", undefined); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("a success from an in-scope provider clears the member, other outcomes leave it", () => { + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 429 }); + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 403 }); + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 500 }); + assert.equal(memory.isProxyAvoided(KEY), true); + noteProxyOutcome("opencode-go", { proxy: PROXY, upstreamStatus: 200 }); + assert.equal(memory.isProxyAvoided(KEY), false); +}); + +test("a success from another provider only ends an unreachable period, not a refusal one", () => { + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 429 }); + noteProxyOutcome("openai", { proxy: PROXY, upstreamStatus: 200 }); + assert.equal( + memory.isProxyAvoided(KEY), + true, + "a shared pool must not clear another scope's refusal" + ); + + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(KEY, "proxy_unreachable"); + noteProxyOutcome("openai", { proxy: PROXY, upstreamStatus: 200 }); + assert.equal(memory.isProxyAvoided(KEY), false); +}); + +test("an edge relay is ignored: its status is the relay's", () => { + noteProxyOutcome("opencode", { + proxy: { type: "vercel", host: "relay.example.vercel.app", port: 443 }, + upstreamStatus: 429, + }); + noteProxyOutcome("opencode", { + proxy: { type: "Vercel", host: "relay.example.vercel.app", port: 443 }, + upstreamStatus: 429, + }); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("a second note while the proxy is already set aside changes nothing", () => { + const start = Date.now(); + const periodMs = memory.noteProxyRefusal(KEY, "ip_quota_429", start) ?? 0; + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 429 }); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 1); + assert.equal(memory.isProxyAvoided(KEY, start + periodMs - 1000), true); + assert.equal(memory.isProxyAvoided(KEY, start + periodMs + 1000), false); +}); + +test("with the flag at its default (off) a received refusal writes nothing", () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + for (const provider of egressBucketedLockProviders()) { + noteProxyOutcome(provider, { proxy: PROXY, upstreamStatus: 429 }); + } + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); From ca312d57aa640b4c5a8aac4c78ac7ac1f7891955 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:48:52 +0200 Subject: [PATCH 28/36] feat(proxies): show how many egress IPs actually served a proxy pool (#13581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind the new `PROXY_POOL_EGRESS_OBSERVATION` flag (default off): a line under each proxy pool showing how many distinct egress IPs actually served it over 24h, backed by `GET /api/settings/proxies/pool/egress-observation`. Maintainer rework before merge (kept the idea, no default behavior change): - The route validates its query with Zod (unknown `scope` → 400 instead of silently `global`), error bodies go through `errorResponse()`, the OpenAPI entry documents security, parameters and responses, and the three UI strings exist in every locale. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .env.example | 5 + .../features/13581-pool-egress-observation.md | 1 + config/quality/file-size-baseline.json | 3 +- docs/openapi.yaml | 67 +++++++ docs/reference/ENVIRONMENT.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- .../components/PoolEgressObservation.tsx | 75 +++++++ .../components/ProxyRegistryManager.tsx | 2 + .../proxies/pool/egress-observation/route.ts | 39 ++++ src/i18n/messages/am.json | 8 +- src/i18n/messages/ar.json | 4 + src/i18n/messages/az.json | 4 + src/i18n/messages/bg.json | 4 + src/i18n/messages/bn.json | 4 + src/i18n/messages/cs.json | 4 + src/i18n/messages/da.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/el.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/et.json | 4 + src/i18n/messages/fa.json | 4 + src/i18n/messages/fi.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ga.json | 4 + src/i18n/messages/gu.json | 4 + src/i18n/messages/ha.json | 8 +- src/i18n/messages/he.json | 4 + src/i18n/messages/hi.json | 4 + src/i18n/messages/hr.json | 4 + src/i18n/messages/hu.json | 4 + src/i18n/messages/hy.json | 8 +- src/i18n/messages/id.json | 4 + src/i18n/messages/ig.json | 8 +- src/i18n/messages/it.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ka.json | 8 +- src/i18n/messages/km.json | 4 + src/i18n/messages/kn.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/lt.json | 4 + src/i18n/messages/lv.json | 4 + src/i18n/messages/ml.json | 4 + src/i18n/messages/mr.json | 4 + src/i18n/messages/ms.json | 4 + src/i18n/messages/mt.json | 4 + src/i18n/messages/my.json | 4 + src/i18n/messages/ne.json | 4 + src/i18n/messages/nl.json | 4 + src/i18n/messages/no.json | 4 + src/i18n/messages/or.json | 4 + src/i18n/messages/pa.json | 4 + src/i18n/messages/phi.json | 4 + src/i18n/messages/pl.json | 4 + src/i18n/messages/pt-BR.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/ro.json | 4 + src/i18n/messages/ru.json | 4 + src/i18n/messages/si.json | 4 + src/i18n/messages/sk.json | 4 + src/i18n/messages/sl.json | 4 + src/i18n/messages/sr.json | 4 + src/i18n/messages/sv.json | 4 + src/i18n/messages/sw.json | 4 + src/i18n/messages/ta.json | 4 + src/i18n/messages/te.json | 4 + src/i18n/messages/th.json | 4 + src/i18n/messages/tr.json | 4 + src/i18n/messages/uk-UA.json | 4 + src/i18n/messages/ur.json | 4 + src/i18n/messages/uz.json | 8 +- src/i18n/messages/vi.json | 4 + src/i18n/messages/yo.json | 8 +- src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + src/lib/db/proxyLogs.ts | 46 +++++ src/lib/proxyPoolEgressObservation.ts | 60 ++++++ .../constants/featureFlagDefinitions.ts | 12 ++ src/shared/utils/featureFlags.ts | 16 ++ src/shared/validation/schemas/proxy.ts | 17 ++ tests/unit/feature-flags-settings.test.ts | 12 +- ...roxy-pool-egress-observation-route.test.ts | 159 +++++++++++++++ .../proxy-pool-egress-observation.test.ts | 187 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- tests/unit/ui/PoolEgressObservation.test.tsx | 96 +++++++++ 85 files changed, 1079 insertions(+), 20 deletions(-) create mode 100644 changelog.d/features/13581-pool-egress-observation.md create mode 100644 src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx create mode 100644 src/app/api/settings/proxies/pool/egress-observation/route.ts create mode 100644 src/lib/proxyPoolEgressObservation.ts create mode 100644 tests/unit/proxy-pool-egress-observation-route.test.ts create mode 100644 tests/unit/proxy-pool-egress-observation.test.ts create mode 100644 tests/unit/ui/PoolEgressObservation.test.tsx diff --git a/.env.example b/.env.example index 4bf685dc94..e5888d0852 100644 --- a/.env.example +++ b/.env.example @@ -2237,6 +2237,11 @@ APP_LOG_TO_FILE=true # proxy — only the operator sets active/inactive (a flaky probe must not strand an # assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour. # PROXY_HEALTH_AUTO_DEACTIVATE=false +# Opt-in feature flag (default off; a dashboard DB override wins over this value): show, +# under a proxy pool in the dashboard, how many observed egress IPs served its members over +# the last 24 h and how many connections used them (read-only, computed from the proxy log, +# never used for routing). "true" (or 1, yes) enables it. +# PROXY_POOL_EGRESS_OBSERVATION=false # Allow OAuth and provider validation flows to bypass a pinned proxy and connect # directly when proxy reachability pre-checks fail. Default: false. diff --git a/changelog.d/features/13581-pool-egress-observation.md b/changelog.d/features/13581-pool-egress-observation.md new file mode 100644 index 0000000000..c535c5f2d5 --- /dev/null +++ b/changelog.d/features/13581-pool-egress-observation.md @@ -0,0 +1 @@ +- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 5cfee5c354..93eb1f9d32 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,6 +1,7 @@ { "_rebaseline_2026_09_15_13602_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1214. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13580_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1202. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_13_13581_pool_egress_observation": "PR #13581 own growth: src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx 1475->1477 (+2 = the PoolEgressObservation import and its one-line mount under the pool members label). The observation itself lives outside the frozen file, all under cap: PoolEgressObservation.tsx, the dedicated GET /api/settings/proxies/pool/egress-observation route, src/lib/proxyPoolEgressObservation.ts and getPoolEgressObservation in src/lib/db/proxyLogs.ts. Only the mount point is irreducible. Covered by tests/unit/proxy-pool-egress-observation.test.ts, tests/unit/proxy-pool-egress-observation-route.test.ts and tests/unit/ui/PoolEgressObservation.test.tsx.", "_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.", @@ -472,7 +473,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, "src/app/(dashboard)/dashboard/providers/page.tsx": 2025, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1222, - "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1475, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1477, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1607, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5e49a388dc..95d9778f48 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -11543,6 +11543,73 @@ paths: responses: "200": description: OK + /api/settings/proxies/pool/egress-observation: + get: + tags: + - Settings + summary: "GET settings › proxies › pool › egress observation" + description: >- + Read-only observation of how many distinct egress IPs actually served the members of a + proxy pool over the last 24 h, read from the proxy log (numbers only, never used for + routing). Opt-in through the PROXY_POOL_EGRESS_OBSERVATION feature flag: while it is + off, or when the read fails, the body is JSON null. Results are cached for 30 seconds + per normalized scope (key is read as account, global as the stored global pool). + security: + - ManagementSessionAuth: [] + parameters: + - name: scope + in: query + required: true + schema: + type: string + enum: + - global + - provider + - account + - combo + - key + - name: scopeId + in: query + required: false + description: Required for every scope except global; ignored for global. + schema: + type: string + maxLength: 256 + responses: + "200": + description: The observation, or null when the feature flag is off or the read failed. + content: + application/json: + schema: + type: object + nullable: true + required: + - connections + - distinctExits + - maxConnectionsOnOneExit + - windowHours + properties: + connections: + type: integer + minimum: 0 + description: Distinct OmniRoute connections logged through the pool members. + distinctExits: + type: integer + minimum: 0 + description: Distinct egress IPs observed for those members. + maxConnectionsOnOneExit: + type: integer + minimum: 0 + description: Most connections seen behind one egress IP over the window. + windowHours: + type: integer + example: 24 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "500": + $ref: "#/components/responses/InternalError" /api/settings/proxy/cloudflare-deploy: post: tags: diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f29f0ad0aa..691e21d019 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1114,6 +1114,7 @@ Anthropic-compatible provider instead. | `PROXY_HEALTH_TEST_STAGGER_MS` | `100` | `src/lib/proxyHealth/probeTarget.ts` | Delay in ms between two probe departures inside a batch. Without it the whole batch leaves at the same moment and a shared egress IP can trip a rate-limited target. Set to `0` to disable the spacing; capped at 5000. | | `PROXY_HEALTH_USE_PROVIDER_TARGET` | `true` | `src/lib/proxyHealth/providerProbeTarget.ts` | Set "false" to stop probing the real host of a proxy's assigned provider (`GET /models`, no API key) and always use `PROXY_HEALTH_TEST_URL` instead. | | `PROXY_HEALTH_AUTO_DEACTIVATE` | `false` | `src/lib/proxyHealth/statusPolicy.ts` | When `false` (default), automated reachability probes (the scheduler + the `/api/settings/proxies/auto-test` "Test All" button) are **read-only** and never write a proxy's status — only the operator sets active/inactive, so a flaky probe can't strand an assigned proxy (#6246). Set `true` to restore the legacy test-and-set behaviour. | +| `PROXY_POOL_EGRESS_OBSERVATION` | `false` | `src/shared/utils/featureFlags.ts` | Opt-in feature flag (see [FEATURE_FLAGS.md](./FEATURE_FLAGS.md); a dashboard DB override wins). `true` (or `1`, `yes`) shows the read-only pool egress observation under a proxy pool in the dashboard (distinct egress IPs, connections and the most seen behind one IP over the last 24 h, from the proxy log). Never used for routing. | | `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. | | `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). | | `PROXY_AUTO_DISABLE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler soft-disable (status `dead`, never deleted) a proxy after repeated consecutive failures, instead of removing it. Non-destructive alternative to `PROXY_AUTO_REMOVE`: the proxy drops out of pool/rotation resolution immediately (the alive-status filter used by scope-pool resolution already excludes it) and is automatically re-activated once it starts passing probes again. Shares the `PROXY_AUTO_REMOVE_AFTER` threshold. If both flags are `true`, `PROXY_AUTO_REMOVE` wins. | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 7803e19937..2523b51bc2 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 -62 flags across 6 categories. **Default** is the definition default — the value +63 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) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (10) +### Network (11) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -74,6 +74,7 @@ used when neither a DB override nor an environment variable is present. | `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | boolean | `false` | | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Off by default because this can change egress IP. | | `NETWORK_ROTATION_SHARED_EGRESS_GUARD` | boolean | `true` | | On a network exception (timeout, connection refused/reset) for a multi-account rotation executor, when the failing account has no dedicated proxy, apply a short cooldown and skip other proxy-less accounts for the rest of the request instead of retrying each one. On by default (safe: no egress IP change, only reduces latency/cooldown risk on shared-egress accounts). Disable to restore immediate propagation on the first proxy-less throw. | | `PROXY_SKIP_RECENTLY_FAILED` | boolean | `false` | | Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default. | +| `PROXY_POOL_EGRESS_OBSERVATION` | boolean | `false` | | Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h and how many connections used them. Read-only, computed from the proxy log, never used for routing. Off by default. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -202,7 +203,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 62 flags + // ... all 63 flags ], "summary": { "total": 56, diff --git a/src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx b/src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx new file mode 100644 index 0000000000..73e39e9f43 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +type Observation = { + connections: number; + distinctExits: number; + maxConnectionsOnOneExit: number; + windowHours: number; +}; + +const OBSERVATION_KEYS = [ + "connections", + "distinctExits", + "maxConnectionsOnOneExit", + "windowHours", +] as const; + +function isObservation(value: unknown): value is Observation { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return OBSERVATION_KEYS.every((key) => typeof record[key] === "number"); +} + +/** + * One line under a pool's member list: how many observed egress IPs actually served its + * members over the window, and how many connections the busiest one carried. Renders + * nothing when the observation is off, failed, or the request itself errored. + */ +export function PoolEgressObservation({ query }: { query: string }) { + const t = useTranslations("proxyRegistry"); + const [loaded, setLoaded] = useState<{ query: string; observation: Observation | null }>({ + query: "", + observation: null, + }); + + useEffect(() => { + let cancelled = false; + fetch(`/api/settings/proxies/pool/egress-observation?${query}`) + .then((res) => (res.ok ? res.json() : null)) + .then((body: unknown) => { + if (!cancelled) setLoaded({ query, observation: isObservation(body) ? body : null }); + }) + .catch(() => { + if (!cancelled) setLoaded({ query, observation: null }); + }); + return () => { + cancelled = true; + }; + }, [query]); + + const observation = loaded.query === query ? loaded.observation : null; + if (!observation) return null; + + const text = + observation.connections === 0 + ? t("poolEgressObservationEmpty", { hours: observation.windowHours }) + : t("poolEgressObservation", { + exits: observation.distinctExits, + connections: observation.connections, + max: observation.maxConnectionsOnOneExit, + hours: observation.windowHours, + }); + + return ( +

+ {text} +

+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 3af5b91361..2978266e08 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -8,6 +8,7 @@ import { ProxyStatusBadge } from "./ProxyStatusBadge"; import { ProxyHealthCell } from "./ProxyHealthCell"; import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; +import { PoolEgressObservation } from "./PoolEgressObservation"; import { parseBulkImportText, type ParsedProxyEntry, @@ -1238,6 +1239,7 @@ import { + {poolMembers.length === 0 ? (
{t("poolNoMembers")} diff --git a/src/app/api/settings/proxies/pool/egress-observation/route.ts b/src/app/api/settings/proxies/pool/egress-observation/route.ts new file mode 100644 index 0000000000..25c523e30b --- /dev/null +++ b/src/app/api/settings/proxies/pool/egress-observation/route.ts @@ -0,0 +1,39 @@ +import { errorResponse } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { readPoolEgressObservation } from "@/lib/proxyPoolEgressObservation"; +import { proxyPoolEgressObservationQuerySchema } from "@/shared/validation/schemas"; +import { + formatValidationMessage, + isValidationFailure, + validateBody, +} from "@/shared/validation/helpers"; + +// Observed egress spread of a proxy pool, read from the proxy log (numbers only). Kept +// apart from GET /api/settings/proxies/pool on purpose: a failure here answers null and +// can never break the pool editor. Same management-auth tier as the pool route. +// +// GET ?scope=global|provider|account|combo|key&scopeId= +// -> { connections, distinctExits, maxConnectionsOnOneExit, windowHours } +// | null (read failed, or the PROXY_POOL_EGRESS_OBSERVATION feature flag is off) +// -> 400 on an unknown scope or a missing scopeId outside global + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const { searchParams } = new URL(request.url); + const validation = validateBody(proxyPoolEgressObservationQuerySchema, { + scope: searchParams.get("scope") ?? undefined, + scopeId: searchParams.get("scopeId"), + }); + if (isValidationFailure(validation)) { + return errorResponse(400, formatValidationMessage(validation.error)); + } + const { scope, scopeId } = validation.data; + return Response.json( + readPoolEgressObservation(scope, scope === "global" ? null : (scopeId ?? null)) + ); + } catch { + return errorResponse(500, "Failed to load pool egress observation"); + } +} diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index 5adfdbefb6..b603a8b0cc 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -10970,7 +10970,10 @@ "poolAddMember": "ጨምር", "poolAddFailed": "ፕሮክሲውን ወደ ጥምረቱ ማከል አልተሳካም", "poolSelectProxy": "ፕሮክሲ ይምረጡ…", - "poolSaveFailed": "የጥምረት ምደባውን ማስቀመጥ አልተሳካም" + "poolSaveFailed": "የጥምረት ምደባውን ማስቀመጥ አልተሳካም", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "የሞዴል መሞከሪያ", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 10290375db..ae3c30d9ed 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "الصفحة الرئيسية", "dashboard": "لوحة القيادة", @@ -10960,6 +10961,9 @@ "strategyLatency": "محسن لزمن الاستجابة", "poolMembersLabel": "أعضاء التجمع ({count})", "poolNoMembers": "لا توجد وكلاء في هذا التجمع بعد.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "إزالة", "poolRemoveFailed": "فشل إزالة الوكيل من التجمع", "poolAddLabel": "إضافة وكيل", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 26baba322e..1eb33376f8 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Gecikməyə görə optimallaşdırılmış", "poolMembersLabel": "Hovuz üzvləri ({count})", "poolNoMembers": "Bu hovuzda hələ proksi yoxdur.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Çıxar", "poolRemoveFailed": "Proksini hovuzdan çıxarmaq mümkün olmadı", "poolAddLabel": "Proksi əlavə et", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index d3acc9516d..ad8afe6c2a 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Начало", "dashboard": "Табло", @@ -10960,6 +10961,9 @@ "strategyLatency": "Оптимизирано по латентност (Latency-optimized)", "poolMembersLabel": "Членове на пула ({count})", "poolNoMembers": "Все още няма проксита в този пул.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Премахване", "poolRemoveFailed": "Неуспешно премахване на проксито от пула", "poolAddLabel": "Добавяне на прокси", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 0978b991ae..f99249654f 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "লেটেন্সি-অপ্টিমাইজড", "poolMembersLabel": "পুলের সদস্য ({count})", "poolNoMembers": "এই পুলে এখনও কোনো প্রক্সি নেই।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "সরান", "poolRemoveFailed": "পুল থেকে প্রক্সি সরাতে ব্যর্থ হয়েছে", "poolAddLabel": "একটি প্রক্সি যোগ করুন", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 51fea2bddb..c07cf93a0c 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Domov", "dashboard": "Nástěnka", @@ -10960,6 +10961,9 @@ "strategyLatency": "Optimalizováno podle latence", "poolMembersLabel": "Členové poolu ({count})", "poolNoMembers": "V tomto poolu zatím nejsou žádné proxy.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Odebrat", "poolRemoveFailed": "Nepodařilo se odebrat proxy z poolu", "poolAddLabel": "Přidat proxy", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index b168bc0e0f..8f082501c6 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Hjem", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latensoptimeret", "poolMembersLabel": "Pool-medlemmer ({count})", "poolNoMembers": "Ingen proxyer i denne pool endnu.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Fjern", "poolRemoveFailed": "Kunne ikke fjerne proxyen fra poolen", "poolAddLabel": "Tilføj en proxy", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4e8c94f099..aef04f0fb1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Zuhause", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latenzoptimiert", "poolMembersLabel": "Pool-Mitglieder ({count})", "poolNoMembers": "Noch keine Proxys in diesem Pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Entfernen", "poolRemoveFailed": "Fehler beim Entfernen des Proxys aus dem Pool", "poolAddLabel": "Proxy hinzufügen", diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 768e9570ce..c323602e2d 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Αρχική", "dashboard": "Πίνακας Ελέγχου", @@ -10967,6 +10968,9 @@ "strategyLatency": "Βελτιστοποιημένο βάσει καθυστέρησης", "poolMembersLabel": "Μέλη pool ({count})", "poolNoMembers": "Δεν υπάρχουν ακόμα proxy σε αυτό το pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Αφαίρεση", "poolRemoveFailed": "Αποτυχία αφαίρεσης του proxy από το pool", "poolAddLabel": "Προσθήκη διαμεσολαβητή", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8da7633ca7..b61b867946 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10967,6 +10968,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Pool members ({count})", "poolNoMembers": "No proxies in this pool yet.", + "poolEgressObservation": "{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "No traffic observed in the last {hours} h", + "poolEgressObservationHint": "Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Remove", "poolRemoveFailed": "Failed to remove the proxy from the pool", "poolAddLabel": "Add a proxy", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5c0ec66cc5..f7a882b578 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Inicio", "dashboard": "Panel de control", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Pool members ({count})", "poolNoMembers": "No proxies in this pool yet.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Remove", "poolRemoveFailed": "Failed to remove the proxy from the pool", "poolAddLabel": "Add a proxy", diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index ce99350990..648ae2e3b3 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Avaleht", "dashboard": "Juhtpaneel", @@ -10967,6 +10968,9 @@ "strategyLatency": "Latentsusoptimeeritud", "poolMembersLabel": "Kogumi liikmed ({count})", "poolNoMembers": "Selles kogumis pole veel ühtegi puhverserverit.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Eemalda", "poolRemoveFailed": "Puhverserveri eemaldamine kogumist nurjus", "poolAddLabel": "Lisa puhverserver", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index ef987f90ed..877068ab40 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "بهینه‌شده برای تاخیر", "poolMembersLabel": "اعضای استخر ({count})", "poolNoMembers": "هنوز هیچ پروکسی در این استخر وجود ندارد.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "حذف", "poolRemoveFailed": "حذف پروکسی از استخر ناموفق بود", "poolAddLabel": "افزودن یک پروکسی", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 13de365f85..62d5ae1958 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Kotiin", "dashboard": "Kojelauta", @@ -10960,6 +10961,9 @@ "strategyLatency": "Viiveoptimoitu", "poolMembersLabel": "Poolin jäsenet ({count})", "poolNoMembers": "Tässä poolissa ei ole vielä proxyja.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Poista", "poolRemoveFailed": "Proxyn poistaminen poolista epäonnistui", "poolAddLabel": "Lisää proxy", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index ed95039836..9990c782e0 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Accueil", "dashboard": "Tableau de bord", @@ -10960,6 +10961,9 @@ "strategyLatency": "Optimisé pour la latence", "poolMembersLabel": "Membres du pool ({count})", "poolNoMembers": "Aucun proxy dans ce pool pour le moment.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Retirer", "poolRemoveFailed": "Échec du retrait du proxy du pool", "poolAddLabel": "Ajouter un proxy", diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 9fe66f178e..1d4b9ba4a8 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Baile", "dashboard": "Deais", @@ -10967,6 +10968,9 @@ "strategyLatency": "Optamaithe ag moill", "poolMembersLabel": "Comhaltaí na linne ({count})", "poolNoMembers": "Níl aon seachfhreastalaithe sa linn seo fós.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Bain", "poolRemoveFailed": "Theip ar bhaint an seachfhreastalaí as an linn", "poolAddLabel": "Cuir procsí leis", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 47e12e2230..1281772931 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "લેટન્સી-ઓપ્ટિમાઇઝ્ડ", "poolMembersLabel": "પૂલ સભ્યો ({count})", "poolNoMembers": "આ પૂલમાં હજી સુધી કોઈ પ્રોક્સી નથી.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "દૂર કરો", "poolRemoveFailed": "પૂલમાંથી પ્રોક્સી દૂર કરવામાં નિષ્ફળ", "poolAddLabel": "પ્રોક્સી ઉમેરો", diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index b001e3b88b..e66a08d2cb 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Ƙara", "poolAddFailed": "An kasa ƙara proxy ɗin zuwa rukuni", "poolSelectProxy": "Zaɓi proxy…", - "poolSaveFailed": "An kasa adana rabon rukuni" + "poolSaveFailed": "An kasa adana rabon rukuni", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Filin Gwajin Model", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index c42c47b297..e658103abc 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "בית", "dashboard": "לוח מחוונים", @@ -10960,6 +10961,9 @@ "strategyLatency": "מותאם השהיה", "poolMembersLabel": "חברי המאגר ({count})", "poolNoMembers": "אין עדיין שרתי פרוקסי במאגר זה.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "הסר", "poolRemoveFailed": "הסרת הפרוקסי מהמאגר נכשלה", "poolAddLabel": "הוסף פרוקסי", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 6f9e16d656..80493c7256 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "घर", "dashboard": "डैशबोर्ड", @@ -10960,6 +10961,9 @@ "strategyLatency": "लेटेंसी-ऑप्टिमाइज़्ड", "poolMembersLabel": "पूल सदस्य ({count})", "poolNoMembers": "इस पूल में अभी तक कोई प्रॉक्सी नहीं है।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "हटाएं", "poolRemoveFailed": "पूल से प्रॉक्सी को हटाने में विफल", "poolAddLabel": "एक प्रॉक्सी जोड़ें", diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 01ff9b4955..7c63e210b9 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Početna", "dashboard": "Nadzorna ploča", @@ -10967,6 +10968,9 @@ "strategyLatency": "Optimizirano prema latenciji", "poolMembersLabel": "Članovi poola ({count})", "poolNoMembers": "Još nema proxyja u ovom poolu.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Ukloni", "poolRemoveFailed": "Uklanjanje proxyja iz poola nije uspjelo", "poolAddLabel": "Dodaj proxy", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 595b1ad84f..f00d6fb7f1 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Otthon", "dashboard": "Irányítópult", @@ -10960,6 +10961,9 @@ "strategyLatency": "Késleltetésre optimalizált", "poolMembersLabel": "Készlet tagjai ({count})", "poolNoMembers": "Még nincsenek proxyk ebben a készletben.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Eltávolítás", "poolRemoveFailed": "Nem sikerült eltávolítani a proxyt a készletből", "poolAddLabel": "Proxy hozzáadása", diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index b1f75934d6..5e3f84831f 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Ավելացնել", "poolAddFailed": "Չհաջողվեց պրոքսին ավելացնել պուլում", "poolSelectProxy": "Ընտրեք պրոքսի…", - "poolSaveFailed": "Չհաջողվեց պահպանել պուլին վերագրումը" + "poolSaveFailed": "Չհաջողվեց պահպանել պուլին վերագրումը", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Մոդելների փորձարկման հարթակ", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 383117cdff..ddfa1bda49 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Rumah", "dashboard": "Dasbor", @@ -10960,6 +10961,9 @@ "strategyLatency": "Dioptimalkan untuk latensi", "poolMembersLabel": "Anggota pool ({count})", "poolNoMembers": "Belum ada proksi di pool ini.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Hapus", "poolRemoveFailed": "Gagal menghapus proksi dari pool", "poolAddLabel": "Tambahkan proksi", diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 410ce9ecac..3ae1d07e2e 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Tinye", "poolAddFailed": "Ịtinye proxy ahụ na pool dara", "poolSelectProxy": "Họrọ proxy…", - "poolSaveFailed": "Ịchekwa nkesa pool dara" + "poolSaveFailed": "Ịchekwa nkesa pool dara", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Ebe Nnwale Model", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 3a685887e9..5251b6ffda 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Casa", "dashboard": "Pannello di controllo", @@ -10960,6 +10961,9 @@ "strategyLatency": "Ottimizzato per la latenza", "poolMembersLabel": "Membri del pool ({count})", "poolNoMembers": "Nessun proxy ancora presente in questo pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Rimuovi", "poolRemoveFailed": "Impossibile rimuovere il proxy dal pool", "poolAddLabel": "Aggiungi un proxy", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index b9a8a298df..08d4c1b8ce 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ホーム", "dashboard": "ダッシュボード", @@ -10960,6 +10961,9 @@ "strategyLatency": "レイテンシー最適化", "poolMembersLabel": "プールメンバー ({count})", "poolNoMembers": "このプールにはまだプロキシがありません。", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "削除", "poolRemoveFailed": "プールからプロキシを削除できませんでした", "poolAddLabel": "プロキシを追加", diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index df26793a98..319da64cc8 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -10970,7 +10970,10 @@ "poolAddMember": "დამატება", "poolAddFailed": "პროქსის პულში დამატება ვერ მოხერხდა", "poolSelectProxy": "აირჩიეთ პროქსი…", - "poolSaveFailed": "პულზე მინიჭების შენახვა ვერ მოხერხდა" + "poolSaveFailed": "პულზე მინიჭების შენახვა ვერ მოხერხდა", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "მოდელების სატესტო გარემო", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 4870095f0a..b49f1109a1 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ទំព័រដើម", "dashboard": "ផ្ទាំងគ្រប់គ្រង", @@ -10967,6 +10968,9 @@ "strategyLatency": "បង្កើនប្រសិទ្ធភាពតាមភាពយឺតយ៉ាវ", "poolMembersLabel": "សមាជិកក្រុម ({count})", "poolNoMembers": "មិនទាន់មានប្រូកស៊ីនៅក្នុងក្រុមនេះទេ។", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ដកចេញ", "poolRemoveFailed": "បានបរាជ័យក្នុងការដកប្រូកស៊ីចេញពីក្រុម", "poolAddLabel": "បន្ថែមប្រូកស៊ី", diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index ab80351c8c..d49265d15e 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ಹೋಮ್", "dashboard": "ಡ್ಯಾಶ್ಬೋರ್ಡ್", @@ -10967,6 +10968,9 @@ "strategyLatency": "ಲೇಟೆನ್ಸಿ-ಆಪ್ಟಿಮೈಸ್ಡ್", "poolMembersLabel": "ಪೂಲ್ ಸದಸ್ಯರು ({count})", "poolNoMembers": "ಈ ಪೂಲ್ನಲ್ಲಿ ಇನ್ನೂ ಯಾವುದೇ ಪ್ರಾಕ್ಸಿಗಳಿಲ್ಲ.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ತೆಗೆದುಹಾಕಿ", "poolRemoveFailed": "ಪೂಲ್ನಿಂದ ಪ್ರಾಕ್ಸಿಯನ್ನು ತೆಗೆದುಹಾಕಲು ವಿಫಲವಾಗಿದೆ", "poolAddLabel": "ಪ್ರಾಕ್ಸಿಯನ್ನು ಸೇರಿಸಿ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f1cdddc39f..8e00e95902 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "홈", "dashboard": "대시보드", @@ -10960,6 +10961,9 @@ "strategyLatency": "지연 시간 최적화", "poolMembersLabel": "풀 멤버 ({count})", "poolNoMembers": "이 풀에 아직 프록시가 없습니다.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "제거", "poolRemoveFailed": "풀에서 프록시를 제거하지 못했습니다", "poolAddLabel": "프록시 추가", diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 5a372d0111..974b09f4a1 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Pradžia", "dashboard": "Suvestinė", @@ -10967,6 +10968,9 @@ "strategyLatency": "Optimizuota pagal delsą", "poolMembersLabel": "Telkinio nariai ({count})", "poolNoMembers": "Šiame telkinyje dar nėra tarpinių serverių.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Pašalinti", "poolRemoveFailed": "Nepavyko pašalinti tarpinio serverio iš telkinio", "poolAddLabel": "Pridėti tarpinį serverį", diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index cd8d0b54c4..122ccb2945 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Sākums", "dashboard": "Panelis", @@ -10967,6 +10968,9 @@ "strategyLatency": "Ar aizkaves optimizāciju", "poolMembersLabel": "Kopas dalībnieki ({count})", "poolNoMembers": "Šajā kopā vēl nav starpniekserveru.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Noņemt", "poolRemoveFailed": "Neizdevās noņemt starpniekserveri no kopas", "poolAddLabel": "Pievienot proksi", diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 1e02b702c1..4eec155d71 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ഹോം", "dashboard": "ഡാഷ്ബോർഡ്", @@ -10967,6 +10968,9 @@ "strategyLatency": "ലേറ്റൻസി-ഒപ്റ്റിമൈസ്ഡ്", "poolMembersLabel": "പൂൾ അംഗങ്ങൾ ({count})", "poolNoMembers": "ഈ പൂളിൽ ഇതുവരെ പ്രോക്സികളൊന്നുമില്ല.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "നീക്കം ചെയ്യുക", "poolRemoveFailed": "പൂളിൽ നിന്ന് പ്രോക്സി നീക്കം ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", "poolAddLabel": "ഒരു പ്രോക്സി ചേർക്കുക", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 6092c38804..1cc5249ee3 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "लेटन्सी-ऑप्टिमाइझ्ड", "poolMembersLabel": "पूल सदस्य ({count})", "poolNoMembers": "या पूलमध्ये अद्याप कोणतेही प्रॉक्सी नाहीत.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "काढून टाका", "poolRemoveFailed": "पूलमधून प्रॉक्सी काढून टाकण्यात अयशस्वी", "poolAddLabel": "प्रॉक्सी जोडा", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index b675d4ce67..9862385d21 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Rumah", "dashboard": "Papan pemuka", @@ -10960,6 +10961,9 @@ "strategyLatency": "Dioptimumkan kependaman", "poolMembersLabel": "Ahli kolam ({count})", "poolNoMembers": "Belum ada proksi dalam kolam ini.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Alih keluar", "poolRemoveFailed": "Gagal mengalih keluar proksi daripada kolam", "poolAddLabel": "Tambah proksi", diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index a01855a407..a56773b3ee 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Paġna Ewlenija", "dashboard": "Dashboard", @@ -10967,6 +10968,9 @@ "strategyLatency": "Ottimizzata għal-latenza", "poolMembersLabel": "Membri tal-pool ({count})", "poolNoMembers": "Għad m’hemm l-ebda proxy f’dan il-pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Neħħi", "poolRemoveFailed": "Ma rnexxiex it-tneħħija tal-proxy mill-pool", "poolAddLabel": "Żid proxy", diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 1423098d98..1c33c5778a 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ပင်မစာမျက်နှာ", "dashboard": "ဒက်ရှ်ဘုတ်", @@ -10967,6 +10968,9 @@ "strategyLatency": "တုံ့ပြန်ချိန်အကောင်းဆုံး", "poolMembersLabel": "ပူးလ်အဖွဲ့ဝင်များ ({count})", "poolNoMembers": "ဤပူးလ်တွင် ပရောက်စီများ မရှိသေးပါ။", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ဖယ်ရှားရန်", "poolRemoveFailed": "ပရောက်စီကို ပူးလ်မှ ဖယ်ရှား၍ မရပါ", "poolAddLabel": "ပရောက်စီတစ်ခု ထည့်ရန်", diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 53f931d8b7..8bca7b76fa 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "गृहपृष्ठ", "dashboard": "ड्यासबोर्ड", @@ -10967,6 +10968,9 @@ "strategyLatency": "लेटेन्सी-अनुकूलित", "poolMembersLabel": "पुल सदस्यहरू ({count})", "poolNoMembers": "यस पुलमा अहिलेसम्म कुनै प्रोक्सी छैन।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "हटाउनुहोस्", "poolRemoveFailed": "पुलबाट प्रोक्सी हटाउन असफल भयो", "poolAddLabel": "प्रोक्सी थप्नुहोस्", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index df7b71607c..8190f95ea8 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Thuis", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latentie-geoptimaliseerd", "poolMembersLabel": "Poolleden ({count})", "poolNoMembers": "Nog geen proxy's in deze pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Verwijderen", "poolRemoveFailed": "Kan de proxy niet uit de pool verwijderen", "poolAddLabel": "Een proxy toevoegen", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index f746178136..0afe15ce1a 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Hjem", "dashboard": "Dashbord", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latensoptimalisert", "poolMembersLabel": "Pool-medlemmer ({count})", "poolNoMembers": "Ingen proxyer i denne poolen ennå.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Fjern", "poolRemoveFailed": "Kunne ikke fjerne proxyen fra poolen", "poolAddLabel": "Legg til en proxy", diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index ac514fefed..f467d7b2c6 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ମୂଳପୃଷ୍ଠା", "dashboard": "ଡ୍ୟାସ୍ବୋର୍ଡ", @@ -10967,6 +10968,9 @@ "strategyLatency": "ଲେଟେନ୍ସି-ଅପ୍ଟିମାଇଜ୍ଡ", "poolMembersLabel": "ପୁଲ୍ ସଦସ୍ୟ ({count})", "poolNoMembers": "ଏହି ପୁଲ୍ରେ ଏପର୍ଯ୍ୟନ୍ତ କୌଣସି ପ୍ରକ୍ସି ନାହିଁ।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ହଟାନ୍ତୁ", "poolRemoveFailed": "ପୁଲ୍ରୁ ପ୍ରକ୍ସିକୁ ହଟାଇବାରେ ବିଫଳ", "poolAddLabel": "ଏକ ପ୍ରକ୍ସି ଯୋଡ଼ନ୍ତୁ", diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 529190cb92..5621de4593 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "ਮੁੱਖ ਪੰਨਾ", "dashboard": "ਡੈਸ਼ਬੋਰਡ", @@ -10967,6 +10968,9 @@ "strategyLatency": "ਲੇਟੈਂਸੀ-ਅਨੁਕੂਲਿਤ", "poolMembersLabel": "ਪੂਲ ਮੈਂਬਰ ({count})", "poolNoMembers": "ਇਸ ਪੂਲ ਵਿੱਚ ਹਾਲੇ ਕੋਈ ਪ੍ਰੌਕਸੀ ਨਹੀਂ ਹੈ।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ਹਟਾਓ", "poolRemoveFailed": "ਪੂਲ ਵਿੱਚੋਂ ਪ੍ਰੌਕਸੀ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ", "poolAddLabel": "ਇੱਕ ਪ੍ਰੌਕਸੀ ਜੋੜੋ", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 80e0ce2dce..cb2622ded0 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Bahay", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Mga miyembro ng pool ({count})", "poolNoMembers": "Wala pang mga proxy sa pool na ito.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Alisin", "poolRemoveFailed": "Hindi naalis ang proxy mula sa pool", "poolAddLabel": "Magdagdag ng proxy", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index bb5ae3ff4d..78dd9df2fa 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Strona główna", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Członkowie puli ({count})", "poolNoMembers": "Brak proxy w tej puli.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Usuń", "poolRemoveFailed": "Nie udało się usunąć proxy z puli", "poolAddLabel": "Dodaj proxy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7e70c730eb..cff2facef2 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -996,6 +996,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", + "featureFlagProxyPoolEgressObservationDescription": "Mostra, abaixo de um pool de proxy no painel, quantos IPs de saída observados atenderam seus membros nas últimas 24 h, quantas conexões os usaram e o máximo visto atrás de um mesmo IP. Somente leitura, calculado a partir do log de proxy, nunca usado para roteamento. Desligado por padrão: o editor de pool não muda e a rota de observação responde null.", "sidebar": { "home": "Início", "dashboard": "Painel", @@ -10968,6 +10969,9 @@ "strategyLatency": "Otimizado por latência", "poolMembersLabel": "Membros do pool ({count})", "poolNoMembers": "Ainda não há proxies neste pool.", + "poolEgressObservation": "{exits, plural, one {# saída} other {# saídas}} usada(s) por {connections, plural, one {# conexão} other {# conexões}} · até {max} em uma saída · últimas {hours} h", + "poolEgressObservationEmpty": "Nenhum tráfego observado nas últimas {hours} h", + "poolEgressObservationHint": "Contado a partir do log de proxy na janela: IPs de saída distintos observados para os membros deste pool e as conexões do OmniRoute que os usaram. \"Em uma saída\" significa ao longo da janela, não ao mesmo tempo. O bloqueio de 429 por IP só pausa conexões da mesma família de provedor.", "poolRemove": "Remover", "poolRemoveFailed": "Falha ao remover o proxy do pool", "poolAddLabel": "Adicionar um proxy", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index e13a6b8c4a..8d21498bb8 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -996,6 +996,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Página inicial", "dashboard": "Painel", @@ -10961,6 +10962,9 @@ "strategyLatency": "Otimizado para latência", "poolMembersLabel": "Membros do pool ({count})", "poolNoMembers": "Ainda não existem proxies neste pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Remover", "poolRemoveFailed": "Falha ao remover o proxy do pool", "poolAddLabel": "Adicionar um proxy", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index e282d96aaf..9f5eaf6ad0 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Acasă", "dashboard": "Tabloul de bord", @@ -10960,6 +10961,9 @@ "strategyLatency": "Optimizat pentru latență", "poolMembersLabel": "Membrii pool-ului ({count})", "poolNoMembers": "Nu există încă proxy-uri în acest pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Elimină", "poolRemoveFailed": "Nu s-a putut elimina proxy-ul din pool", "poolAddLabel": "Adaugă un proxy", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index e4eae57468..893b959fc3 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Главная", "dashboard": "Панель управления", @@ -10960,6 +10961,9 @@ "strategyLatency": "Оптимизация по задержке", "poolMembersLabel": "Участники пула ({count})", "poolNoMembers": "В этом пуле пока нет прокси.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Удалить", "poolRemoveFailed": "Не удалось удалить прокси из пула", "poolAddLabel": "Добавить прокси", diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 13970b4857..a5e8942f85 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "මුල් පිටුව", "dashboard": "උපකරණ පුවරුව", @@ -10967,6 +10968,9 @@ "strategyLatency": "ප්රමාදයට ප්රශස්ත කළ", "poolMembersLabel": "සංචිත සාමාජිකයන් ({count})", "poolNoMembers": "මෙම සංචිතයේ තවමත් ප්රොක්සි නොමැත.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ඉවත් කරන්න", "poolRemoveFailed": "සංචිතයෙන් ප්රොක්සිය ඉවත් කිරීමට අසමත් විය", "poolAddLabel": "ප්රොක්සියක් එක් කරන්න", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 9de0478de6..8c53476a9d 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Domov", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Optimalizované podľa latencie", "poolMembersLabel": "Členovia poolu ({count})", "poolNoMembers": "V tomto poole zatiaľ nie sú žiadne proxy servery.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Odstrániť", "poolRemoveFailed": "Nepodarilo sa odstrániť proxy z poolu", "poolAddLabel": "Pridať proxy", diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 2a6356d030..65d0b21c19 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Domov", "dashboard": "Nadzorna plošča", @@ -10967,6 +10968,9 @@ "strategyLatency": "Optimizirano glede na zakasnitev", "poolMembersLabel": "Člani skupine ({count})", "poolNoMembers": "V tej skupini še ni posredniških strežnikov.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Odstrani", "poolRemoveFailed": "Posredniškega strežnika ni bilo mogoče odstraniti iz skupine", "poolAddLabel": "Dodaj posredniški strežnik", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 9cb8b326eb..e172e28ef9 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Почетна", "dashboard": "Контролна табла", @@ -10967,6 +10968,9 @@ "strategyLatency": "Оптимизовано по кашњењу", "poolMembersLabel": "Чланови групе ({count})", "poolNoMembers": "Нема proxy-ja у овој групи још.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Уклони", "poolRemoveFailed": "Уклањање proxy-ja из групе није успело", "poolAddLabel": "Додај proxy", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 7dec4bf7f9..8eee268fb0 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Hem", "dashboard": "Instrumentpanel", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latensoptimerad", "poolMembersLabel": "Poolmedlemmar ({count})", "poolNoMembers": "Inga proxyservrar i denna pool ännu.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Ta bort", "poolRemoveFailed": "Misslyckades med att ta bort proxyn från poolen", "poolAddLabel": "Lägg till en proxy", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 0cea30f10a..63dd5731a4 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Iliyoboreshwa kwa ucheleweshaji", "poolMembersLabel": "Wanachama wa dimbwi ({count})", "poolNoMembers": "Hakuna proksi katika dimbwi hili bado.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Ondoa", "poolRemoveFailed": "Imeshindwa kuondoa proksi kwenye dimbwi", "poolAddLabel": "Ongeza proksi", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 42002a90e1..cbcbdcf894 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "தொகுப்பு உறுப்பினர்கள் ({count})", "poolNoMembers": "இந்தத் தொகுப்பில் இன்னும் proxy-கள் எதுவும் இல்லை.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "அகற்று", "poolRemoveFailed": "தொகுப்பிலிருந்து proxy-ஐ அகற்றுவது தோல்வியடைந்தது", "poolAddLabel": "ஒரு proxy-ஐச் சேர்", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index df63a0a8b0..bae17a83f5 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "లేటెన్సీ-ఆప్టిమైజ్డ్", "poolMembersLabel": "పూల్ సభ్యులు ({count})", "poolNoMembers": "ఈ పూల్‌లో ఇంకా ప్రాక్సీలు లేవు.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "తొలగించు", "poolRemoveFailed": "పూల్ నుండి ప్రాక్సీని తొలగించడం విఫలమైంది", "poolAddLabel": "ప్రాక్సీని జోడించండి", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index f4f26570ab..c4e94551cf 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "บ้าน", "dashboard": "แดชบอร์ด", @@ -10960,6 +10961,9 @@ "strategyLatency": "ปรับตามเวลาแฝงให้เหมาะสม", "poolMembersLabel": "สมาชิกในพูล ({count})", "poolNoMembers": "ยังไม่มีพร็อกซีในพูลนี้", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ลบออก", "poolRemoveFailed": "ลบพร็อกซีออกจากพูลไม่สำเร็จ", "poolAddLabel": "เพิ่มพร็อกซี", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index a087d0a8ff..faacc35043 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Ana Sayfa", "dashboard": "Kontrol Paneli", @@ -10960,6 +10961,9 @@ "strategyLatency": "Gecikme optimizasyonlu", "poolMembersLabel": "Havuz üyeleri ({count})", "poolNoMembers": "Bu havuzda henüz proxy yok.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Kaldır", "poolRemoveFailed": "Proxy havuzdan kaldırılamadı", "poolAddLabel": "Proxy ekle", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 1de6b3a5fe..3aee5d5785 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "додому", "dashboard": "Приладова панель", @@ -10960,6 +10961,9 @@ "strategyLatency": "Оптимізований за затримкою", "poolMembersLabel": "Учасники пулу ({count})", "poolNoMembers": "У цьому пулі ще немає проксі.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Видалити", "poolRemoveFailed": "Не вдалося видалити проксі з пулу", "poolAddLabel": "Додати проксі", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 7eb3bc2214..355644f65c 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10960,6 +10961,9 @@ "strategyLatency": "لیٹنسی-آپٹمائزڈ", "poolMembersLabel": "پول کے ارکان ({count})", "poolNoMembers": "اس پول میں ابھی تک کوئی پروکسی نہیں ہے۔", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ہٹائیں", "poolRemoveFailed": "پول سے پروکسی کو ہٹانے میں ناکامی", "poolAddLabel": "ایک پروکسی شامل کریں", diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 3c79019870..46f3f68f83 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Qo‘shish", "poolAddFailed": "Proksini pulga qo‘shib bo‘lmadi", "poolSelectProxy": "Proksini tanlang…", - "poolSaveFailed": "Pulga biriktirishni saqlab bo‘lmadi" + "poolSaveFailed": "Pulga biriktirishni saqlab bo‘lmadi", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Model sinov maydoni", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index b0d0c6fb94..cdae76b2fe 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -996,6 +996,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", + "featureFlagProxyPoolEgressObservationDescription": "Hiển thị, bên dưới một nhóm proxy trong bảng điều khiển, số IP đầu ra quan sát được đã phục vụ các thành viên của nhóm trong 24 giờ qua, số kết nối đã dùng chúng và số lớn nhất thấy sau cùng một IP. Chỉ đọc, tính từ nhật ký proxy, không bao giờ dùng để định tuyến. Tắt theo mặc định: trình chỉnh sửa nhóm không đổi và tuyến quan sát trả về null.", "sidebar": { "home": "Trang chủ", "dashboard": "Trang tổng quan", @@ -10968,6 +10969,9 @@ "strategyLatency": "Tối ưu độ trễ (Latency-optimized)", "poolMembersLabel": "Thành viên nhóm proxy ({count})", "poolNoMembers": "Chưa có proxy nào trong nhóm này.", + "poolEgressObservation": "{exits, plural, other {# lối ra}} được dùng bởi {connections, plural, other {# kết nối}} · tối đa {max} trên một lối ra · {hours} giờ qua", + "poolEgressObservationEmpty": "Không ghi nhận lưu lượng trong {hours} giờ qua", + "poolEgressObservationHint": "Tính từ nhật ký proxy trong khoảng thời gian: số IP đầu ra khác nhau quan sát được của các thành viên nhóm proxy này và số kết nối OmniRoute đã dùng chúng. \"Trên một lối ra\" nghĩa là trong cả khoảng thời gian, không phải cùng lúc. Khóa 429 theo IP chỉ tạm dừng các kết nối cùng họ nhà cung cấp.", "poolRemove": "Gỡ bỏ", "poolRemoveFailed": "Không thể gỡ proxy khỏi nhóm", "poolAddLabel": "Thêm proxy", diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index b24a3e3406..a39d82398d 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Ṣàfikún", "poolAddFailed": "Kùnà láti ṣàfikún aṣojú sí àkójọpọ̀ náà", "poolSelectProxy": "Yan aṣojú kan…", - "poolSaveFailed": "Kùnà láti fi ìpín àkójọpọ̀ pamọ́" + "poolSaveFailed": "Kùnà láti fi ìpín àkójọpọ̀ pamọ́", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Pápá Ìdánwò Àwòṣe", @@ -14160,5 +14163,6 @@ "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.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation." + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e8b7795f68..12f14cb900 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "首页", "dashboard": "仪表板", @@ -10960,6 +10961,9 @@ "strategyLatency": "延迟优化", "poolMembersLabel": "池成员 ({count})", "poolNoMembers": "此池中尚无代理。", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "移除", "poolRemoveFailed": "从池中移除代理失败", "poolAddLabel": "添加代理", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 500eeb7737..b2e0d0853f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -995,6 +995,7 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "sidebar": { "home": "首頁", "dashboard": "儀表板", @@ -10960,6 +10961,9 @@ "strategyLatency": "延遲最佳化", "poolMembersLabel": "池成員({count})", "poolNoMembers": "此池中尚無代理。", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "移除", "poolRemoveFailed": "從池中移除代理失敗", "poolAddLabel": "新增代理", diff --git a/src/lib/db/proxyLogs.ts b/src/lib/db/proxyLogs.ts index bb1e6ea7b3..f4b0530088 100644 --- a/src/lib/db/proxyLogs.ts +++ b/src/lib/db/proxyLogs.ts @@ -67,3 +67,49 @@ export function getRecentEgressIpForConnection( if (!row) return null; return { egressIp: row.egress_ip, at: row.timestamp }; } + +export type PoolEgressObservationCounts = { + connections: number; + distinctExits: number; + maxConnectionsOnOneExit: number; +}; + +/** + * How many distinct observed egress IPs served a proxy pool's members since `since`, how + * many OmniRoute connections went through them, and the most connections seen behind one + * egress IP over that window. Members are matched to log rows by host and port, so two + * registry rows sharing one entry point count together. Only numbers leave this function. + * `scope` and `scopeId` must already be normalized (normalizeScope and + * normalizeAssignmentScopeId); an empty pool simply matches no rows. + */ +export function getPoolEgressObservation( + scope: string, + scopeId: string | null, + since: string +): PoolEgressObservationCounts { + const db = getDbInstance(); + const perExit = db + .prepare( + `SELECT COUNT(DISTINCT l.connection_id) AS n + FROM proxy_logs l + JOIN proxy_registry r ON l.proxy_host = r.host AND l.proxy_port = r.port + WHERE r.id IN (SELECT proxy_id FROM proxy_assignments WHERE scope = ? AND scope_id IS ?) + AND l.timestamp >= ? AND l.egress_ip IS NOT NULL AND l.connection_id IS NOT NULL + GROUP BY l.egress_ip` + ) + .all(scope, scopeId, since) as Array<{ n: number }>; + const total = db + .prepare( + `SELECT COUNT(DISTINCT l.connection_id) AS n + FROM proxy_logs l + JOIN proxy_registry r ON l.proxy_host = r.host AND l.proxy_port = r.port + WHERE r.id IN (SELECT proxy_id FROM proxy_assignments WHERE scope = ? AND scope_id IS ?) + AND l.timestamp >= ? AND l.egress_ip IS NOT NULL AND l.connection_id IS NOT NULL` + ) + .get(scope, scopeId, since) as { n: number }; + return { + connections: total.n, + distinctExits: perExit.length, + maxConnectionsOnOneExit: perExit.reduce((max, row) => Math.max(max, row.n), 0), + }; +} diff --git a/src/lib/proxyPoolEgressObservation.ts b/src/lib/proxyPoolEgressObservation.ts new file mode 100644 index 0000000000..f7210fc931 --- /dev/null +++ b/src/lib/proxyPoolEgressObservation.ts @@ -0,0 +1,60 @@ +/** + * Observed egress spread of a proxy pool, for the dashboard pool editor. Read-only and + * never on the routing path: a failure returns null so it cannot break the pool screen. + * Opt-in through the PROXY_POOL_EGRESS_OBSERVATION feature flag (default off: null, the + * line stays hidden). The scope is normalized exactly like the pool read (key -> account, + * global -> "__global__"), and a result is cached for 30 seconds per normalized scope. + */ +import { + EGRESS_IP_LOOKUP_WINDOW_MS, + getPoolEgressObservation, + type PoolEgressObservationCounts, +} from "@/lib/db/proxyLogs"; +import { normalizeAssignmentScopeId, normalizeScope } from "@/lib/db/proxies/mappers"; +import { flushProxyLogsSync } from "@/lib/proxyLogger"; +import { isPoolEgressObservationEnabled } from "@/shared/utils/featureFlags"; + +export type PoolEgressObservation = PoolEgressObservationCounts & { windowHours: number }; + +const CACHE_TTL_MS = 30_000; +const CACHE_MAX_ENTRIES = 200; + +const cache = new Map(); + +export function readPoolEgressObservation( + scope: string, + scopeId: string | null, + nowMs: number = Date.now() +): PoolEgressObservation | null { + if (!isPoolEgressObservationEnabled()) return null; + const normalizedScope = normalizeScope(scope); + const normalizedScopeId = normalizeAssignmentScopeId(normalizedScope, scopeId); + const key = `${normalizedScope}:${normalizedScopeId ?? ""}`; + + const hit = cache.get(key); + if (hit && nowMs - hit.at < CACHE_TTL_MS) return hit.value; + + let value: PoolEgressObservation; + try { + flushProxyLogsSync(); + const since = new Date(nowMs - EGRESS_IP_LOOKUP_WINDOW_MS).toISOString(); + value = { + ...getPoolEgressObservation(normalizedScope, normalizedScopeId, since), + windowHours: EGRESS_IP_LOOKUP_WINDOW_MS / (60 * 60 * 1000), + }; + } catch { + // Observer only: a failed read hides the line instead of failing the pool screen. + return null; + } + + if (!cache.has(key) && cache.size >= CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { at: nowMs, value }); + return value; +} + +export function resetPoolEgressObservationCache(): void { + cache.clear(); +} diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 28a10a01b5..ef1f5d7c6b 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -203,6 +203,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "PROXY_POOL_EGRESS_OBSERVATION", + label: "Proxy Pool Egress Observation", + description: + "Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + descriptionI18nKey: "featureFlagProxyPoolEgressObservationDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 875b158cf9..d108dbe527 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -189,6 +189,22 @@ export function isProxySkipRecentlyFailedEnabled(): boolean { } } +/** + * Pool egress observation panel (#13581): read-only dashboard line under a proxy pool. + * Opt-in; an unreadable flag store keeps it hidden. + */ +export function isPoolEgressObservationEnabled(): boolean { + try { + return isFeatureFlagEnabled("PROXY_POOL_EGRESS_OBSERVATION"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve PROXY_POOL_EGRESS_OBSERVATION, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/src/shared/validation/schemas/proxy.ts b/src/shared/validation/schemas/proxy.ts index 9fc0391652..baff42ae86 100644 --- a/src/shared/validation/schemas/proxy.ts +++ b/src/shared/validation/schemas/proxy.ts @@ -234,6 +234,23 @@ export const proxyPoolMemberSchema = z } }); +// GET /api/settings/proxies/pool/egress-observation query (#13581). Same scope vocabulary and +// scopeId rule as the pool routes: an unknown scope is rejected, never read as "global". +export const proxyPoolEgressObservationQuerySchema = z + .object({ + scope: z.enum(["global", "provider", "account", "combo", "key"]), + scopeId: z.string().trim().max(256).nullable().optional(), + }) + .superRefine((value, ctx) => { + if (value.scope !== "global" && !value.scopeId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "scopeId is required for provider/account/combo/key scope", + path: ["scopeId"], + }); + } + }); + // Set a scope pool's rotation strategy. Optional sticky window (minutes) only // applies to the `sticky` strategy; ignored otherwise. export const proxyRotationStrategySchema = z diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index a5b1f3b785..c31eab4146 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 62; +const EXPECTED_FEATURE_FLAG_COUNT = 63; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -225,6 +225,16 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines the pool egress observation as a network boolean flag disabled by default", () => { + // Guards the UI default: the read-only panel under a proxy pool stays hidden unless opted in. + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "PROXY_POOL_EGRESS_OBSERVATION"); + assert.ok(def, "PROXY_POOL_EGRESS_OBSERVATION should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { // Guards the egress default: with this on, /v1/audio/* may reach a provider node // hosted outside localhost. It must never become an implicit default (cf. #3963). diff --git a/tests/unit/proxy-pool-egress-observation-route.test.ts b/tests/unit/proxy-pool-egress-observation-route.test.ts new file mode 100644 index 0000000000..f3d63b7656 --- /dev/null +++ b/tests/unit/proxy-pool-egress-observation-route.test.ts @@ -0,0 +1,159 @@ +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 { randomUUID } from "node:crypto"; + +// The pool egress observation has its own route so that a failure there can never break +// GET /api/settings/proxies/pool. These tests pin its shape, its null-on-failure contract, +// its opt-in flag (PROXY_POOL_EGRESS_OBSERVATION, default off) and its Zod parameter checks. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-egress-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; +delete process.env.INITIAL_PASSWORD; // auth not required in this test env + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const observation = await import("../../src/lib/proxyPoolEgressObservation.ts"); +const { GET } = await import("../../src/app/api/settings/proxies/pool/egress-observation/route.ts"); +const poolRoute = await import("../../src/app/api/settings/proxies/pool/route.ts"); + +function resetStorage() { + delete process.env.INITIAL_PASSWORD; + process.env.PROXY_POOL_EGRESS_OBSERVATION = "true"; + observation.resetPoolEgressObservationCache(); + 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(() => { + delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function request(base: string, query: Record): Request { + const params = new URLSearchParams(query); + return new Request(`http://localhost${base}?${params.toString()}`, { method: "GET" }); +} + +const OBSERVATION_PATH = "/api/settings/proxies/pool/egress-observation"; + +async function pooledProxyWithTraffic() { + const proxy = await proxiesDb.createProxy({ + name: "member", + type: "http", + host: "10.9.1.1", + port: 21001, + }); + await proxiesDb.addProxyToScopePool("provider", "openai", proxy.id); + core + .getDbInstance() + .prepare( + `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, level, connection_id, egress_ip) + VALUES (?, ?, 'success', 'http', '10.9.1.1', 21001, 'provider', 'c1', '203.0.113.1')` + ) + .run(randomUUID(), new Date(Date.now() - 60_000).toISOString()); +} + +test("returns the observation with exactly the four documented keys", async () => { + await pooledProxyWithTraffic(); + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "openai" })); + assert.equal(response.status, 200); + const body = (await response.json()) as Record; + assert.deepEqual(Object.keys(body).sort(), [ + "connections", + "distinctExits", + "maxConnectionsOnOneExit", + "windowHours", + ]); + assert.deepEqual(body, { + connections: 1, + distinctExits: 1, + maxConnectionsOnOneExit: 1, + windowHours: 24, + }); +}); + +test("returns zeros for an empty pool and leaves the pool route untouched", async () => { + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "nobody" })); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + connections: 0, + distinctExits: 0, + maxConnectionsOnOneExit: 0, + windowHours: 24, + }); + + const pool = await poolRoute.GET( + request("/api/settings/proxies/pool", { scope: "provider", scopeId: "nobody" }) + ); + assert.equal(pool.status, 200); + const poolBody = (await pool.json()) as Record; + assert.deepEqual(Object.keys(poolBody).sort(), ["members", "strategy", "total"]); +}); + +test("returns null with status 200 when the read fails", async () => { + await pooledProxyWithTraffic(); + const db = core.getDbInstance(); + db.exec("ALTER TABLE proxy_logs RENAME TO proxy_logs_hidden"); + try { + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "openai" })); + assert.equal(response.status, 200); + assert.equal(await response.json(), null); + } finally { + db.exec("ALTER TABLE proxy_logs_hidden RENAME TO proxy_logs"); + } +}); + +test("returns null when the feature flag is at its default (off) or turned off", async () => { + await pooledProxyWithTraffic(); + for (const value of [undefined, "false"]) { + if (value === undefined) delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + else process.env.PROXY_POOL_EGRESS_OBSERVATION = value; + observation.resetPoolEgressObservationCache(); + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "openai" })); + assert.equal(response.status, 200); + assert.equal(await response.json(), null, String(value)); + } +}); + +test("rejects an unknown scope with 400 instead of reading the global pool", async () => { + const global = await proxiesDb.createProxy({ + name: "global member", + type: "http", + host: "10.9.2.2", + port: 21002, + }); + await proxiesDb.addProxyToScopePool("global", null, global.id); + for (const scope of ["globals", "GLOBAL", "tenant", " "]) { + const response = await GET(request(OBSERVATION_PATH, { scope, scopeId: "openai" })); + assert.equal(response.status, 400, scope); + const body = (await response.json()) as { error?: { message?: string } }; + assert.equal(typeof body?.error?.message, "string"); + assert.ok(!body.error!.message!.includes("at /"), "no stack trace in the error body"); + } +}); + +test("rejects an over-long scopeId with 400", async () => { + const response = await GET( + request(OBSERVATION_PATH, { scope: "provider", scopeId: "x".repeat(300) }) + ); + assert.equal(response.status, 400); +}); + +test("rejects a missing scope, or a missing scopeId outside global, like the pool route", async () => { + const noScope = await GET(request(OBSERVATION_PATH, {})); + assert.equal(noScope.status, 400); + const noScopeId = await GET(request(OBSERVATION_PATH, { scope: "provider" })); + assert.equal(noScopeId.status, 400); + const global = await GET(request(OBSERVATION_PATH, { scope: "global" })); + assert.equal(global.status, 200); +}); diff --git a/tests/unit/proxy-pool-egress-observation.test.ts b/tests/unit/proxy-pool-egress-observation.test.ts new file mode 100644 index 0000000000..a9d4ea6269 --- /dev/null +++ b/tests/unit/proxy-pool-egress-observation.test.ts @@ -0,0 +1,187 @@ +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 { randomUUID } from "node:crypto"; + +// A pool's member count says nothing about how many egress IPs really served it. These +// tests pin the observation read from the proxy log: numbers only, the scope normalized +// the same way the pool itself is read, and any failure isolated as null. The observation is +// opt-in (PROXY_POOL_EGRESS_OBSERVATION feature flag, default off): tests opt in explicitly. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-egress-obs-")); +process.env.DATA_DIR = TEST_DATA_DIR; +delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const observation = await import("../../src/lib/proxyPoolEgressObservation.ts"); + +const HOUR_MS = 60 * 60 * 1000; +let nextPort = 20000; + +function resetStorage() { + observation.resetPoolEgressObservationCache(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + process.env.PROXY_POOL_EGRESS_OBSERVATION = "true"; + resetStorage(); +}); + +test.after(() => { + delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +type Member = { host: string; port: number }; + +async function registryProxy(): Promise { + nextPort++; + const proxy = await proxiesDb.createProxy({ + name: `proxy ${nextPort}`, + type: "http", + host: "10.9.0.1", + port: nextPort, + }); + return { id: proxy.id, host: "10.9.0.1", port: nextPort }; +} + +async function poolMember(scope: string, scopeId: string | null): Promise { + const proxy = await registryProxy(); + await proxiesDb.addProxyToScopePool(scope, scopeId, proxy.id); + return proxy; +} + +function logRow( + member: Member, + egressIp: string | null, + connectionId: string | null, + ageMs = HOUR_MS +) { + core + .getDbInstance() + .prepare( + `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, level, connection_id, egress_ip) + VALUES (?, ?, 'success', 'http', ?, ?, 'provider', ?, ?)` + ) + .run( + randomUUID(), + new Date(Date.now() - ageMs).toISOString(), + member.host, + member.port, + connectionId, + egressIp + ); +} + +test("counts distinct exits, connections and the busiest exit of a pool", async () => { + const a = await poolMember("provider", "openai"); + const b = await poolMember("provider", "openai"); + const c = await poolMember("provider", "openai"); + for (const conn of ["c1", "c2", "c3", "c4"]) logRow(a, "203.0.113.1", conn); + for (const conn of ["c5", "c6", "c7"]) logRow(a, "203.0.113.2", conn); + for (const conn of ["c8", "c9"]) logRow(b, "203.0.113.3", conn); + for (const conn of ["c10", "c11"]) logRow(b, "203.0.113.4", conn); + logRow(c, "203.0.113.5", "c12"); + logRow(c, "203.0.113.5", "c1"); + logRow(c, "203.0.113.5", "c12"); + + const outsider = await registryProxy(); + logRow(outsider, "198.51.100.9", "x1"); + logRow(a, null, "x2"); + logRow(a, "203.0.113.9", null); + logRow(a, "203.0.113.8", "x3", 25 * HOUR_MS); + + assert.deepEqual(observation.readPoolEgressObservation("provider", "openai"), { + connections: 12, + distinctExits: 5, + maxConnectionsOnOneExit: 4, + windowHours: 24, + }); +}); + +test("the global pool is read under its stored scope id", async () => { + const member = await poolMember("global", null); + logRow(member, "203.0.113.1", "c1"); + assert.deepEqual(observation.readPoolEgressObservation("global", null), { + connections: 1, + distinctExits: 1, + maxConnectionsOnOneExit: 1, + windowHours: 24, + }); +}); + +test("scope key is the account scope and shares its cache entry", async () => { + const member = await poolMember("account", "acc-1"); + logRow(member, "203.0.113.1", "c1"); + const now = Date.now(); + assert.equal(observation.readPoolEgressObservation("account", "acc-1", now)?.connections, 1); + + logRow(member, "203.0.113.1", "c2"); + assert.equal(observation.readPoolEgressObservation("key", "acc-1", now + 1000)?.connections, 1); + + observation.resetPoolEgressObservationCache(); + assert.equal(observation.readPoolEgressObservation("key", "acc-1", now + 2000)?.connections, 2); +}); + +test("an empty pool yields zeros, not null", async () => { + assert.deepEqual(observation.readPoolEgressObservation("provider", "nobody"), { + connections: 0, + distinctExits: 0, + maxConnectionsOnOneExit: 0, + windowHours: 24, + }); +}); + +test("a result is reused for 30 seconds, then read again", async () => { + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + const now = Date.now(); + assert.equal(observation.readPoolEgressObservation("provider", "openai", now)?.connections, 1); + + logRow(member, "203.0.113.2", "c2"); + const cached = observation.readPoolEgressObservation("provider", "openai", now + 29_000); + assert.equal(cached?.connections, 1); + const fresh = observation.readPoolEgressObservation("provider", "openai", now + 31_000); + assert.equal(fresh?.connections, 2); +}); + +test("any SQL failure yields null and is not cached", async () => { + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + const db = core.getDbInstance(); + db.exec("ALTER TABLE proxy_logs RENAME TO proxy_logs_hidden"); + try { + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null); + } finally { + db.exec("ALTER TABLE proxy_logs_hidden RENAME TO proxy_logs"); + } + assert.equal(observation.readPoolEgressObservation("provider", "openai")?.connections, 1); +}); + +test("the observation stays off unless the feature flag is on", async () => { + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null, "default"); + for (const value of ["false", "0", "no", "off"]) { + process.env.PROXY_POOL_EGRESS_OBSERVATION = value; + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null, value); + } + process.env.PROXY_POOL_EGRESS_OBSERVATION = "true"; + assert.equal(observation.readPoolEgressObservation("provider", "openai")?.connections, 1); +}); + +test("a DB override turning the flag off wins over the environment", async () => { + const flagsDb = await import("../../src/lib/db/featureFlags.ts"); + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + flagsDb.setFeatureFlagOverride("PROXY_POOL_EGRESS_OBSERVATION", "false"); + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 9aa46264d8..8b5c6c4315 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, 62); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 63); }); }); diff --git a/tests/unit/ui/PoolEgressObservation.test.tsx b/tests/unit/ui/PoolEgressObservation.test.tsx new file mode 100644 index 0000000000..209522366b --- /dev/null +++ b/tests/unit/ui/PoolEgressObservation.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key, +})); + +import { PoolEgressObservation } from "@/app/(dashboard)/dashboard/settings/components/PoolEgressObservation"; + +const QUERY = "scope=provider&scopeId=openai"; +let root: Root | null = null; +let container: HTMLElement | null = null; + +async function renderWith(fetchImpl: (...args: unknown[]) => Promise) { + const fetchMock = vi.fn(fetchImpl); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render(React.createElement(PoolEgressObservation, { query: QUERY })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return { fetchMock, element: container }; +} + +function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) }); +} + +describe("PoolEgressObservation", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("reads the dedicated route and shows the counts", async () => { + const { fetchMock, element } = await renderWith(() => + jsonResponse({ + connections: 12, + distinctExits: 5, + maxConnectionsOnOneExit: 4, + windowHours: 24, + }) + ); + expect(fetchMock).toHaveBeenCalledWith( + `/api/settings/proxies/pool/egress-observation?${QUERY}` + ); + expect(element.textContent).toBe( + 'poolEgressObservation:{"exits":5,"connections":12,"max":4,"hours":24}' + ); + }); + + it("says no traffic was observed when the pool had no connection", async () => { + const { element } = await renderWith(() => + jsonResponse({ + connections: 0, + distinctExits: 0, + maxConnectionsOnOneExit: 0, + windowHours: 24, + }) + ); + expect(element.textContent).toBe('poolEgressObservationEmpty:{"hours":24}'); + }); + + it("renders nothing when the route answers null", async () => { + const { element } = await renderWith(() => jsonResponse(null)); + expect(element.textContent).toBe(""); + }); + + it("renders nothing on an error status", async () => { + const { element } = await renderWith(() => jsonResponse({ error: "nope" }, false)); + expect(element.textContent).toBe(""); + }); + + it("renders nothing when the request fails", async () => { + const { element } = await renderWith(() => Promise.reject(new Error("offline"))); + expect(element.textContent).toBe(""); + }); +}); From 5cf4316b670708976a6ff0711c4f88b5744e9746 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:01:25 +0200 Subject: [PATCH 29/36] fix(proxy-health): refused probe responses reset the consecutive-failure streak (#13608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind the new `PROXY_HEALTH_BLOCKED_RESETS_STREAK` flag (default off), a probe the target refuses (401/403/429) resets the proxy's consecutive-failure streak, so a proxy that clearly relays is not marked dead by spaced-out real failures. Maintainer rework before merge (kept the idea, no default behavior change): - The original reversed the deliberate #10654 policy for everyone; with the flag off a refusal stays neutral, and the existing assertions are restored. The stale JSDoc and the wrong "any relayed response resets" comment are fixed (5xx stays inconclusive). - The source-grep test became a real sweep test: a local relay answering 403 drives fail → blocked → fail with auto-disable, in both flag modes. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13608-blocked-resets-streak.md | 1 + docs/reference/FEATURE_FLAGS.md | 17 +- src/i18n/messages/am.json | 3 +- src/i18n/messages/ar.json | 1 + src/i18n/messages/az.json | 1 + src/i18n/messages/bg.json | 1 + src/i18n/messages/bn.json | 1 + src/i18n/messages/cs.json | 1 + src/i18n/messages/da.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/el.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/et.json | 1 + src/i18n/messages/fa.json | 1 + src/i18n/messages/fi.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ga.json | 1 + src/i18n/messages/gu.json | 1 + src/i18n/messages/ha.json | 3 +- src/i18n/messages/he.json | 1 + src/i18n/messages/hi.json | 1 + src/i18n/messages/hr.json | 1 + src/i18n/messages/hu.json | 1 + src/i18n/messages/hy.json | 3 +- src/i18n/messages/id.json | 1 + src/i18n/messages/ig.json | 3 +- src/i18n/messages/it.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ka.json | 3 +- src/i18n/messages/km.json | 1 + src/i18n/messages/kn.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/lt.json | 1 + src/i18n/messages/lv.json | 1 + src/i18n/messages/ml.json | 1 + src/i18n/messages/mr.json | 1 + src/i18n/messages/ms.json | 1 + src/i18n/messages/mt.json | 1 + src/i18n/messages/my.json | 1 + src/i18n/messages/ne.json | 1 + src/i18n/messages/nl.json | 1 + src/i18n/messages/no.json | 1 + src/i18n/messages/or.json | 1 + src/i18n/messages/pa.json | 1 + src/i18n/messages/phi.json | 1 + src/i18n/messages/pl.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/ro.json | 1 + src/i18n/messages/ru.json | 1 + src/i18n/messages/si.json | 1 + src/i18n/messages/sk.json | 1 + src/i18n/messages/sl.json | 1 + src/i18n/messages/sr.json | 1 + src/i18n/messages/sv.json | 1 + src/i18n/messages/sw.json | 1 + src/i18n/messages/ta.json | 1 + src/i18n/messages/te.json | 1 + src/i18n/messages/th.json | 1 + src/i18n/messages/tr.json | 1 + src/i18n/messages/uk-UA.json | 1 + src/i18n/messages/ur.json | 1 + src/i18n/messages/uz.json | 3 +- src/i18n/messages/vi.json | 1 + src/i18n/messages/yo.json | 3 +- src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/proxyHealth/decision.ts | 40 ++++- src/lib/proxyHealth/scheduler.ts | 11 +- .../constants/featureFlagDefinitions.ts | 12 ++ src/shared/utils/featureFlags.ts | 16 ++ tests/unit/feature-flags-settings.test.ts | 14 +- .../unit/proxy-health-blocked-outcome.test.ts | 60 +++++++ .../proxy-health-blocked-streak-sweep.test.ts | 147 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 76 files changed, 373 insertions(+), 27 deletions(-) create mode 100644 changelog.d/fixes/13608-blocked-resets-streak.md create mode 100644 tests/unit/proxy-health-blocked-streak-sweep.test.ts diff --git a/changelog.d/fixes/13608-blocked-resets-streak.md b/changelog.d/fixes/13608-blocked-resets-streak.md new file mode 100644 index 0000000000..3a866921d9 --- /dev/null +++ b/changelog.d/fixes/13608-blocked-resets-streak.md @@ -0,0 +1 @@ +- **fix(proxy-health):** a target-refused probe (401/403/429) can reset the consecutive-failure streak instead of staying neutral, behind the opt-in `PROXY_HEALTH_BLOCKED_RESETS_STREAK` feature flag (default off: refusals keep the #10654 neutral policy); a relayed 5xx stays inconclusive and a refusal never removes or disables a proxy ([#13608](https://github.com/diegosouzapw/OmniRoute/pull/13608)) — thanks @maxmad64bis diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 2523b51bc2..b9580e21ed 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 -63 flags across 6 categories. **Default** is the definition default — the value +64 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) @@ -134,13 +134,14 @@ used when neither a DB override nor an environment variable is present. | `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES` | boolean | `false` | | After a provider model sync, automatically (re)write ~/.codex/*.config.toml profile files from the live catalog. Never changes the active/default Codex config. Off by default. | | `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` | boolean | `false` | | After a provider model sync, automatically (re)write ~/.claude/profiles//settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default. | -### Health (3) +### Health (4) -| Key | Type | Default | Description | -| ------------------------------------- | ------- | ------- | -------------------------------------------------------- | -| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | boolean | `false` | Disable the local instance health check endpoint. | -| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | boolean | `false` | Disable the token validation health check. | -| `SKILLS_SANDBOX_NETWORK_ENABLED` | boolean | `false` | Enable network access in the skills sandbox environment. | +| Key | Type | Default | Description | +| ------------------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | boolean | `false` | Disable the local instance health check endpoint. | +| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | boolean | `false` | Disable the token validation health check. | +| `SKILLS_SANDBOX_NETWORK_ENABLED` | boolean | `false` | Enable network access in the skills sandbox environment. | +| `PROXY_HEALTH_BLOCKED_RESETS_STREAK` | boolean | `false` | In the proxy health sweep, a probe the target refused (401/403/429) resets the proxy's consecutive-failure streak. Off by default: a refusal stays neutral (#10654). A 5xx stays inconclusive either way; a refusal never removes, disables or re-activates a proxy. | > [!NOTE] > `INPUT_SANITIZER_BLOCK_THRESHOLD` and its legacy alias @@ -203,7 +204,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 63 flags + // ... all 64 flags ], "summary": { "total": 56, diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index b603a8b0cc..fdae2f38f0 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index ae3c30d9ed..acc0eebbea 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "الصفحة الرئيسية", "dashboard": "لوحة القيادة", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 1eb33376f8..663d028ab8 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index ad8afe6c2a..4049e2b21d 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Начало", "dashboard": "Табло", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index f99249654f..9437ef38ea 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index c07cf93a0c..af621ae64f 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Domov", "dashboard": "Nástěnka", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 8f082501c6..f4e9a00f2b 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Hjem", "dashboard": "Dashboard", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index aef04f0fb1..011e8a8184 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Zuhause", "dashboard": "Dashboard", diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index c323602e2d..ab028b0ca6 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Αρχική", "dashboard": "Πίνακας Ελέγχου", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b61b867946..c1b6a74794 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f7a882b578..3ce08c427c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Inicio", "dashboard": "Panel de control", diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 648ae2e3b3..1e38d61294 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Avaleht", "dashboard": "Juhtpaneel", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 877068ab40..78f22c2633 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 62d5ae1958..5c0776995c 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Kotiin", "dashboard": "Kojelauta", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9990c782e0..6190cc79ff 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Accueil", "dashboard": "Tableau de bord", diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 1d4b9ba4a8..b2594f676a 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Baile", "dashboard": "Deais", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 1281772931..85eb72dfc8 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index e66a08d2cb..c6b9435d2e 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index e658103abc..a275126e2e 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "בית", "dashboard": "לוח מחוונים", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 80493c7256..c1c1103479 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "घर", "dashboard": "डैशबोर्ड", diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 7c63e210b9..8e5fd6c688 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Početna", "dashboard": "Nadzorna ploča", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index f00d6fb7f1..eb72dc44ed 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Otthon", "dashboard": "Irányítópult", diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 5e3f84831f..2be3692da6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index ddfa1bda49..5ec7c719e0 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Rumah", "dashboard": "Dasbor", diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 3ae1d07e2e..7ce2832f65 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 5251b6ffda..e1cdadec1b 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Casa", "dashboard": "Pannello di controllo", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 08d4c1b8ce..fb2c8658bd 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ホーム", "dashboard": "ダッシュボード", diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index 319da64cc8..a8f036cb29 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index b49f1109a1..df7fa2ac3a 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ទំព័រដើម", "dashboard": "ផ្ទាំងគ្រប់គ្រង", diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index d49265d15e..e089761223 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ಹೋಮ್", "dashboard": "ಡ್ಯಾಶ್ಬೋರ್ಡ್", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 8e00e95902..d80802fa59 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "홈", "dashboard": "대시보드", diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 974b09f4a1..4095245803 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Pradžia", "dashboard": "Suvestinė", diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 122ccb2945..2ddff1c191 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Sākums", "dashboard": "Panelis", diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 4eec155d71..feb1bb1576 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ഹോം", "dashboard": "ഡാഷ്ബോർഡ്", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 1cc5249ee3..daf19a6cae 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 9862385d21..d62c0f7357 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Rumah", "dashboard": "Papan pemuka", diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index a56773b3ee..ce6561f74b 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Paġna Ewlenija", "dashboard": "Dashboard", diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 1c33c5778a..cb89cf7fc2 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ပင်မစာမျက်နှာ", "dashboard": "ဒက်ရှ်ဘုတ်", diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 8bca7b76fa..3b67620b21 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "गृहपृष्ठ", "dashboard": "ड्यासबोर्ड", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 8190f95ea8..e89c107ef0 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Thuis", "dashboard": "Dashboard", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 0afe15ce1a..1bd5cf3bfe 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Hjem", "dashboard": "Dashbord", diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index f467d7b2c6..ad34930fbd 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ମୂଳପୃଷ୍ଠା", "dashboard": "ଡ୍ୟାସ୍ବୋର୍ଡ", diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 5621de4593..5f17b437a6 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ਮੁੱਖ ਪੰਨਾ", "dashboard": "ਡੈਸ਼ਬੋਰਡ", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index cb2622ded0..3231c158d0 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Bahay", "dashboard": "Dashboard", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 78dd9df2fa..e3132e6375 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Strona główna", "dashboard": "Dashboard", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index cff2facef2..bc85934a8c 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -997,6 +997,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", "featureFlagProxyPoolEgressObservationDescription": "Mostra, abaixo de um pool de proxy no painel, quantos IPs de saída observados atenderam seus membros nas últimas 24 h, quantas conexões os usaram e o máximo visto atrás de um mesmo IP. Somente leitura, calculado a partir do log de proxy, nunca usado para roteamento. Desligado por padrão: o editor de pool não muda e a rota de observação responde null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "Na varredura de saúde de proxy, permite que uma sonda recusada pelo destino (401/403/429: o proxy retransmitiu, o destino recusou este IP de saída) zere a sequência de falhas consecutivas do proxy, como uma sonda atendida. Desligado por padrão: a recusa continua neutra e mantém a sequência. Um 5xx continua inconclusivo em qualquer caso, e uma recusa nunca remove, desativa ou reativa um proxy.", "sidebar": { "home": "Início", "dashboard": "Painel", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8d21498bb8..cdea0ba4b8 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -997,6 +997,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Página inicial", "dashboard": "Painel", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 9f5eaf6ad0..906e6cb0be 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Acasă", "dashboard": "Tabloul de bord", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 893b959fc3..956b75d6c2 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Главная", "dashboard": "Панель управления", diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index a5e8942f85..fb970ee279 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "මුල් පිටුව", "dashboard": "උපකරණ පුවරුව", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 8c53476a9d..06cc1d9345 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Domov", "dashboard": "Dashboard", diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 65d0b21c19..22d34add46 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Domov", "dashboard": "Nadzorna plošča", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index e172e28ef9..1a2d81ddb9 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Почетна", "dashboard": "Контролна табла", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 8eee268fb0..d509e3dfbc 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Hem", "dashboard": "Instrumentpanel", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 63dd5731a4..3c0018a144 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index cbcbdcf894..99a3a6cdcd 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index bae17a83f5..d8e4783f7c 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index c4e94551cf..b7eef7ba23 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "บ้าน", "dashboard": "แดชบอร์ด", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index faacc35043..921a100dba 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Ana Sayfa", "dashboard": "Kontrol Paneli", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 3aee5d5785..ed07ebe6cc 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "додому", "dashboard": "Приладова панель", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 355644f65c..21764b19b6 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 46f3f68f83..0242835290 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index cdae76b2fe..a1c085f7cf 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -997,6 +997,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", "featureFlagProxyPoolEgressObservationDescription": "Hiển thị, bên dưới một nhóm proxy trong bảng điều khiển, số IP đầu ra quan sát được đã phục vụ các thành viên của nhóm trong 24 giờ qua, số kết nối đã dùng chúng và số lớn nhất thấy sau cùng một IP. Chỉ đọc, tính từ nhật ký proxy, không bao giờ dùng để định tuyến. Tắt theo mặc định: trình chỉnh sửa nhóm không đổi và tuyến quan sát trả về null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "Trong lượt kiểm tra sức khỏe proxy, cho phép một lần thăm dò bị đích từ chối (401/403/429: proxy đã chuyển tiếp, đích từ chối IP đầu ra này) đặt lại chuỗi lỗi liên tiếp của proxy, giống như một lần thăm dò được phục vụ. Tắt theo mặc định: lần từ chối vẫn trung lập và giữ nguyên chuỗi. Lỗi 5xx vẫn không kết luận trong mọi trường hợp, và lần từ chối không bao giờ xóa, vô hiệu hóa hay kích hoạt lại proxy.", "sidebar": { "home": "Trang chủ", "dashboard": "Trang tổng quan", diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index a39d82398d..9927ae0d18 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -14164,5 +14164,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null." + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 12f14cb900..3947dd5aae 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "首页", "dashboard": "仪表板", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index b2e0d0853f..eec21fe864 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -996,6 +996,7 @@ "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "首頁", "dashboard": "儀表板", diff --git a/src/lib/proxyHealth/decision.ts b/src/lib/proxyHealth/decision.ts index ae58cde705..bac37411d7 100644 --- a/src/lib/proxyHealth/decision.ts +++ b/src/lib/proxyHealth/decision.ts @@ -28,10 +28,16 @@ * both flags are set, auto-remove (destructive) wins: a proxy that is * about to be deleted has no use for a soft-disable in between. * E — a `blocked` probe (the TARGET refused this egress IP: 401/403/429) is - * neutral like `inconclusive`. The proxy relayed correctly, so it is not - * failing; but it is not serving that destination either, which `ok` hid. - * Kept out of the failure count on purpose: one target refusing an IP - * does not make the proxy dead, and the operator owns the removal policy. + * neutral like `inconclusive` by default (#10654). The proxy relayed + * correctly, so it is not failing; but it is not serving that destination + * either, which `ok` hid. Kept out of the failure count on purpose: one + * target refusing an IP does not make the proxy dead, and the operator + * owns the removal policy. + * Opt-in (`blockedResetsStreak`, the PROXY_HEALTH_BLOCKED_RESETS_STREAK + * feature flag): a refusal additionally RESETS the consecutive-failure + * streak, since the proxy demonstrably relayed. It still never counts, + * never sets a status and never removes. This covers 401/403/429 only: a + * relayed 5xx stays `inconclusive` (policy B) and keeps the streak. */ export type ProxyProbeOutcome = "ok" | "fail" | "inconclusive" | "blocked"; @@ -67,6 +73,12 @@ export interface ProxyHealthDecisionInput { autoDisable?: boolean; /** Consecutive conclusive failures required before a downgrade/removal. */ removeAfter: number; + /** + * PROXY_HEALTH_BLOCKED_RESETS_STREAK — operator opted into letting a `blocked` + * probe reset the streak (policy E). Optional/defaults to `false`: `blocked` + * stays neutral, exactly as before. + */ + blockedResetsStreak?: boolean; } export interface ProxyHealthDecision { @@ -81,16 +93,30 @@ export interface ProxyHealthDecision { } export function decideProxyHealthAction(input: ProxyHealthDecisionInput): ProxyHealthDecision { - const { outcome, priorFailures, autoRemove, autoDisable = false, removeAfter } = input; + const { + outcome, + priorFailures, + autoRemove, + autoDisable = false, + removeAfter, + blockedResetsStreak = false, + } = input; const threshold = Number.isFinite(removeAfter) && removeAfter > 0 ? removeAfter : 3; // Either opt-in flag hands status control from the operator to the sweep. const managesStatus = autoRemove || autoDisable; - // B/E: inconclusive and blocked probes are neutral — no count, no status. - if (outcome === "inconclusive" || outcome === "blocked") { + // B/E: inconclusive and (by default) blocked probes are neutral — no count, no status. + if (outcome === "inconclusive" || (outcome === "blocked" && !blockedResetsStreak)) { return { failures: priorFailures, clearFailures: false, setStatus: null, remove: false }; } + // E (opt-in): a refused relay still proves the proxy relayed, so the streak resets. + // Status and removal stay untouched: forgetting failures is not declaring the proxy + // healthy, and one target refusing an IP never removes or disables a proxy. + if (outcome === "blocked") { + return { failures: 0, clearFailures: true, setStatus: null, remove: false }; + } + // Success: reset the streak. Only (re)assert "active" when the operator has // opted into status management; otherwise never touch the user's status (C). if (outcome === "ok") { diff --git a/src/lib/proxyHealth/scheduler.ts b/src/lib/proxyHealth/scheduler.ts index 8bf026ac33..41b4c4f9d4 100644 --- a/src/lib/proxyHealth/scheduler.ts +++ b/src/lib/proxyHealth/scheduler.ts @@ -47,6 +47,7 @@ import { waitForProbeSlot, } from "./probeTarget.ts"; import { resolveProviderProbeTarget } from "./providerProbeTarget.ts"; +import { isProxyHealthBlockedResetsStreakEnabled } from "@/shared/utils/featureFlags"; // #6246: a HEAD to the public probe target through a legit (often loaded) proxy // can exceed a few seconds; the old 5s ceiling produced false negatives that @@ -133,8 +134,10 @@ function isBackgroundServicesDisabled(): boolean { * decision layer can apply the #6246 policy: * - "ok" — the proxy relayed and the target served the request. * - "blocked" — the proxy relayed, but the TARGET refused this egress IP - * (401/403/429). Neutral like "inconclusive": the proxy is - * not at fault, yet it is not serving that destination. + * (401/403/429). Neutral like "inconclusive" by default: the + * proxy is not at fault, yet it is not serving that + * destination. With PROXY_HEALTH_BLOCKED_RESETS_STREAK on it + * also resets the consecutive-failure streak (never a status). * - "inconclusive" — NOT the proxy's fault: our own timeout/abort, or the probe * TARGET returned a 5xx (the proxy connected fine). Never * penalizes the proxy. @@ -210,6 +213,7 @@ async function sweep(): Promise { const removeAfter = getRemoveAfter(); const autoRemove = isAutoRemoveEnabled(); const autoDisable = isAutoDisableEnabled(); + const blockedResetsStreak = isProxyHealthBlockedResetsStreakEnabled(); let tested = 0; let alive = 0; @@ -244,6 +248,7 @@ async function sweep(): Promise { autoRemove, autoDisable, removeAfter, + blockedResetsStreak, }); if (decision.clearFailures) failureMap.delete(id); @@ -273,7 +278,7 @@ async function sweep(): Promise { } console.log( - `${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${blocked} blocked by target, ` + + `${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${blocked} refused by target, ` + `${inconclusive} inconclusive, ${removed} auto-removed, ${disabled} auto-disabled` ); } diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index ef1f5d7c6b..a2d4229e2c 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -761,4 +761,16 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "PROXY_HEALTH_BLOCKED_RESETS_STREAK", + label: "Proxy Health: Refusal Resets Failure Streak", + description: + "In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the proxy's consecutive-failure streak, like a served probe. Off by default: a refusal stays neutral and keeps the streak (#10654). A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", + descriptionI18nKey: "featureFlagProxyHealthBlockedResetsStreakDescription", + category: "health", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, ]; diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index d108dbe527..637de0a472 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -205,6 +205,22 @@ export function isPoolEgressObservationEnabled(): boolean { } } +/** + * Proxy health sweep (#13608): a target-refused probe resets the consecutive-failure streak. + * Opt-in; an unreadable flag store keeps the neutral policy (#10654). + */ +export function isProxyHealthBlockedResetsStreakEnabled(): boolean { + try { + return isFeatureFlagEnabled("PROXY_HEALTH_BLOCKED_RESETS_STREAK"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve PROXY_HEALTH_BLOCKED_RESETS_STREAK, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index c31eab4146..8497ffaa29 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 63; +const EXPECTED_FEATURE_FLAG_COUNT = 64; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -235,6 +235,18 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines blocked-resets-streak as a health boolean flag disabled by default", () => { + // Guards the #10654 default: a target-refused probe stays neutral unless opted in. + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "PROXY_HEALTH_BLOCKED_RESETS_STREAK" + ); + assert.ok(def, "PROXY_HEALTH_BLOCKED_RESETS_STREAK should exist"); + assert.strictEqual(def.category, "health"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { // Guards the egress default: with this on, /v1/audio/* may reach a provider node // hosted outside localhost. It must never become an implicit default (cf. #3963). diff --git a/tests/unit/proxy-health-blocked-outcome.test.ts b/tests/unit/proxy-health-blocked-outcome.test.ts index 3acb447cf2..63ab6d2b59 100644 --- a/tests/unit/proxy-health-blocked-outcome.test.ts +++ b/tests/unit/proxy-health-blocked-outcome.test.ts @@ -103,3 +103,63 @@ test("only the target-refusal statuses are flagged blocked across the whole rang } assert.deepEqual(flagged, [401, 403, 429]); }); + +// ─── policy E, opt-in: PROXY_HEALTH_BLOCKED_RESETS_STREAK (#13608) ─── +// The probe target needs no key, so a refusal is often the normal answer of a healthy proxy +// behind a shared egress IP. With the flag on, a refused relay resets the streak; status and +// removal stay untouched. A relayed 5xx is `inconclusive` and keeps the streak either way. + +test("flag on: blocked resets the streak, never advances it, never touches status", () => { + const d = decideProxyHealthAction({ + outcome: "blocked", + priorFailures: 2, + autoRemove: false, + autoDisable: false, + removeAfter: 3, + blockedResetsStreak: true, + }); + assert.deepEqual(d, { failures: 0, clearFailures: true, setStatus: null, remove: false }); +}); + +test("flag on: blocked still cannot remove, disable or re-activate a proxy", () => { + for (const managed of [ + { autoRemove: true, autoDisable: true }, + { autoRemove: false, autoDisable: true }, + { autoRemove: true, autoDisable: false }, + ]) { + const d = decideProxyHealthAction({ + outcome: "blocked", + priorFailures: 3, + removeAfter: 3, + blockedResetsStreak: true, + ...managed, + }); + assert.equal(d.remove, false); + assert.equal(d.setStatus, null, "a refusal is not proof of health: no re-activation"); + assert.equal(d.failures, 0); + } +}); + +test("flag on: an inconclusive (5xx/timeout) probe still keeps the streak", () => { + const d = decideProxyHealthAction({ + outcome: "inconclusive", + priorFailures: 2, + autoRemove: true, + removeAfter: 3, + blockedResetsStreak: true, + }); + assert.deepEqual(d, { failures: 2, clearFailures: false, setStatus: null, remove: false }); +}); + +test("flag explicitly off is the neutral default", () => { + const input = { + outcome: "blocked" as const, + priorFailures: 2, + autoRemove: false, + removeAfter: 3, + }; + assert.deepEqual( + decideProxyHealthAction({ ...input, blockedResetsStreak: false }), + decideProxyHealthAction(input) + ); +}); diff --git a/tests/unit/proxy-health-blocked-streak-sweep.test.ts b/tests/unit/proxy-health-blocked-streak-sweep.test.ts new file mode 100644 index 0000000000..389d3796c5 --- /dev/null +++ b/tests/unit/proxy-health-blocked-streak-sweep.test.ts @@ -0,0 +1,147 @@ +/** + * #13608 through the real sweep (forceProxyHealthSweep): fail -> blocked -> fail on one + * proxy with PROXY_AUTO_DISABLE=true and a threshold of 2. + * + * - Flag off (default, #10654): the refusal is neutral, the streak survives it, and the + * second failure soft-disables the proxy. + * - PROXY_HEALTH_BLOCKED_RESETS_STREAK on: the refusal resets the streak, so the second + * failure is only the first of a new streak and the proxy keeps its status. + * + * The refusal comes from a local HTTP proxy that relays to a local target answering 403; + * the failures come from the same port with nothing listening (immediate ECONNREFUSED). + * No outbound traffic. The sweep summary line is captured to pin its refusal tally. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-blocked-streak-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; +process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES = "true"; +process.env.PROXY_AUTO_DISABLE = "true"; +process.env.PROXY_AUTO_REMOVE_AFTER = "2"; +process.env.PROXY_HEALTH_TEST_STAGGER_MS = "0"; +delete process.env.PROXY_AUTO_REMOVE; +delete process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK; + +// The probe target: any request is refused, the way a destination refuses an egress IP. +const target = http.createServer((_req, res) => { + res.writeHead(403); + res.end(); +}); +await new Promise((resolve) => target.listen(0, "127.0.0.1", () => resolve())); +const targetPort = (target.address() as net.AddressInfo).port; +process.env.PROXY_HEALTH_TEST_URL = `http://127.0.0.1:${targetPort}/probe`; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const { forceProxyHealthSweep } = await import("../../src/lib/proxyHealth/scheduler.ts"); + +test.after(async () => { + delete process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK; + await new Promise((resolve) => target.close(() => resolve())); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +// A minimal forward proxy on a fixed port: absolute-form requests and CONNECT tunnels both +// reach the refusing target. Tunnel sockets are detached from the server, so they are tracked +// and destroyed on stop: a kept-alive tunnel must not turn the next "fail" into a refusal. +const tunnelSockets = new Set(); + +function startRelay(port: number): Promise { + const relay = http.createServer((req, res) => { + const upstream = http.request( + { host: "127.0.0.1", port: targetPort, method: req.method, path: "/probe" }, + (answer) => { + res.writeHead(answer.statusCode ?? 502); + answer.pipe(res); + } + ); + upstream.on("error", () => res.destroy()); + req.pipe(upstream); + }); + relay.on("connect", (_req, client, head) => { + tunnelSockets.add(client as net.Socket); + const socket = net.connect(targetPort, "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + socket.write(head); + socket.pipe(client); + client.pipe(socket); + }); + tunnelSockets.add(socket); + socket.on("error", () => client.destroy()); + client.on("error", () => socket.destroy()); + }); + return new Promise((resolve) => relay.listen(port, "127.0.0.1", () => resolve(relay))); +} + +function stopRelay(relay: http.Server): Promise { + for (const socket of tunnelSockets) socket.destroy(); + tunnelSockets.clear(); + relay.closeAllConnections(); + return new Promise((resolve) => relay.close(() => resolve())); +} + +async function freePort(): Promise { + const probe = net.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", () => resolve())); + const { port } = probe.address() as net.AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return port; +} + +async function sweepCapturingSummary(): Promise { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + await forceProxyHealthSweep(); + } finally { + console.log = original; + } + return lines.find((line) => line.includes("Sweep complete")) ?? ""; +} + +async function failBlockedFail(): Promise<{ status: string | undefined; summaries: string[] }> { + const port = await freePort(); + const created = await proxiesDb.createProxy({ + name: `flaky ${port}`, + type: "http", + host: "127.0.0.1", + port, + }); + const summaries: string[] = []; + summaries.push(await sweepCapturingSummary()); // nothing listening: fail (streak 1) + const relay = await startRelay(port); + try { + summaries.push(await sweepCapturingSummary()); // relayed, target refused: blocked + } finally { + await stopRelay(relay); + } + summaries.push(await sweepCapturingSummary()); // nothing listening again: fail + const row = await proxiesDb.getProxyById(created!.id, { includeSecrets: false }); + await proxiesDb.deleteProxyById(created!.id, { force: true }); + return { status: (row as { status?: string } | null)?.status, summaries }; +} + +test("flag off (default): a refusal keeps the streak, the second failure disables", async () => { + delete process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK; + const { status, summaries } = await failBlockedFail(); + assert.match(summaries[1], /1 tested, 0 alive, 1 refused by target/); + assert.equal(status, "dead"); +}); + +test("flag on: a refusal resets the streak, the second failure does not disable", async () => { + process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK = "true"; + const { status, summaries } = await failBlockedFail(); + assert.match(summaries[1], /1 tested, 0 alive, 1 refused by target/); + assert.notEqual(status, "dead"); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 8b5c6c4315..9ed43488e2 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, 63); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 64); }); }); From d8c0448293779c14f176685ef52eb9ca5871a913 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:10:57 +0200 Subject: [PATCH 30/36] fix(opencode): rotate once when a Responses stream stalls before its first byte (#13484) Behind the new `OPENCODE_RESPONSES_STALL_ROTATION` flag (default off): a streamed Responses reply with no first body byte within `RESPONSES_FIRST_BYTE_TIMEOUT_MS` (15s) cools the account and rotates once; a second stall fails fast instead of waiting the 80s readiness timeout. Maintainer rework before merge (kept the idea, no default behavior change): - The TLS first-byte watchdog from #12656 is restored byte for byte (the PR had changed its pump, timer and cancel); the stall guard lives in its own module. - Proxy-less multi-account setups now rotate the same way as proxied ones (the original threw for them), a client abort during the wait rethrows instead of rotating, and the env var is documented as flag-only. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .env.example | 2 + ...484-opencode-responses-first-byte-stall.md | 1 + docs/reference/ENVIRONMENT.md | 2 + docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/executors/opencode.ts | 48 ++- open-sse/executors/opencodeResponsesStall.ts | 48 +++ open-sse/utils/firstByteWatchdog.ts | 133 +++++++ .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 17 + src/shared/utils/runtimeTimeouts.ts | 17 + tests/unit/feature-flags-settings.test.ts | 11 +- tests/unit/first-byte-watchdog.test.ts | 143 ++++++++ ...pencode-responses-first-byte-stall.test.ts | 326 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 14 files changed, 757 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/13484-opencode-responses-first-byte-stall.md create mode 100644 open-sse/executors/opencodeResponsesStall.ts create mode 100644 open-sse/utils/firstByteWatchdog.ts create mode 100644 tests/unit/first-byte-watchdog.test.ts create mode 100644 tests/unit/opencode-responses-first-byte-stall.test.ts diff --git a/.env.example b/.env.example index e5888d0852..e73f3203f1 100644 --- a/.env.example +++ b/.env.example @@ -1692,6 +1692,8 @@ CURSOR_USER_AGENT="Cursor/3.4" # ── TLS client (wreq-js fingerprint proxy) ── # TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default # TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables) +# OPENCODE_RESPONSES_STALL_ROTATION=false # #13484 feature flag (Settings → Feature Flags wins): rotate once when a streamed Responses reply stalls before its first byte +# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables) # ── API Bridge (/v1 proxy server) ── # API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min) diff --git a/changelog.d/fixes/13484-opencode-responses-first-byte-stall.md b/changelog.d/fixes/13484-opencode-responses-first-byte-stall.md new file mode 100644 index 0000000000..0ba03b49d5 --- /dev/null +++ b/changelog.d/fixes/13484-opencode-responses-first-byte-stall.md @@ -0,0 +1 @@ +- **fix(opencode):** opt-in `OPENCODE_RESPONSES_STALL_ROTATION` flag (default off): a streamed Responses reply that sends headers and then nothing is cut after `RESPONSES_FIRST_BYTE_TIMEOUT_MS` (default 15 s) instead of waiting for the stream readiness timeout — the account is cooled down and the request rotates to the next account once (proxied or proxy-less), a second stall fails fast; with the flag off nothing changes ([#13484](https://github.com/diegosouzapw/OmniRoute/pull/13484)) — thanks @maxmad64bis diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 691e21d019..c4052e9748 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -738,6 +738,7 @@ REQUEST_TIMEOUT_MS (global override) │ ├─→ FETCH_BODY_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS) │ ├─→ TLS_CLIENT_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS) │ │ └── TLS_FIRST_BYTE_WATCHDOG_MS (independent, default: 10000) +│ ├── RESPONSES_FIRST_BYTE_TIMEOUT_MS (independent, default: 15000) │ ├── FETCH_CONNECT_TIMEOUT_MS (independent, default: 30000) │ └── FETCH_KEEPALIVE_TIMEOUT_MS (independent, default: 4000) ├─→ STREAM_IDLE_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 600000) @@ -776,6 +777,7 @@ REQUEST_TIMEOUT_MS (global override) | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | | `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | | `TLS_FIRST_BYTE_WATCHDOG_MS` | `10000` | Bounds time-to-first-byte on the wreq-js TLS-fingerprint transport's body specifically; `TLS_CLIENT_TIMEOUT_MS` alone cannot catch a stalled body since it resolves as soon as headers arrive (#12656). A timeout cancels the wreq reader and falls back to the direct/proxy dispatcher; `0` disables the watchdog. | +| `RESPONSES_FIRST_BYTE_TIMEOUT_MS` | `15000` | OpenCode executor only, and only while the `OPENCODE_RESPONSES_STALL_ROTATION` feature flag is on (default off): bounds the wait for the first body byte of a streamed Responses reply after its headers (#13484). A Responses stream opens with `response.created`, so silence past this window is a stall: the account is cooled down and the request rotates to the next account once; a second stall fails fast. `0` disables the guard even with the flag on. | | `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | | `FIRECRAWL_BASE_URL` | `https://api.firecrawl.dev` | Point the Firecrawl web-fetch executor at a self-hosted instance (API key optional off-cloud). | | `FIRECRAWL_TIMEOUT_MS` | `30000` | Per-request timeout for the Firecrawl web-fetch executor. | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index b9580e21ed..779325fa7d 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 -64 flags across 6 categories. **Default** is the definition default — the value +65 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) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (11) +### Network (12) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -75,6 +75,7 @@ used when neither a DB override nor an environment variable is present. | `NETWORK_ROTATION_SHARED_EGRESS_GUARD` | boolean | `true` | | On a network exception (timeout, connection refused/reset) for a multi-account rotation executor, when the failing account has no dedicated proxy, apply a short cooldown and skip other proxy-less accounts for the rest of the request instead of retrying each one. On by default (safe: no egress IP change, only reduces latency/cooldown risk on shared-egress accounts). Disable to restore immediate propagation on the first proxy-less throw. | | `PROXY_SKIP_RECENTLY_FAILED` | boolean | `false` | | Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default. | | `PROXY_POOL_EGRESS_OBSERVATION` | boolean | `false` | | Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h and how many connections used them. Read-only, computed from the proxy log, never used for routing. Off by default. | +| `OPENCODE_RESPONSES_STALL_ROTATION` | boolean | `false` | | For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: `RESPONSES_FIRST_BYTE_TIMEOUT_MS`, default `15000`). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -204,7 +205,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 64 flags + // ... all 65 flags ], "summary": { "total": 56, diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index a2b3cb5e92..19a3d82087 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -29,6 +29,11 @@ import { extractChatcmplId, } from "./accountRotation.ts"; import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts"; +import { + guardResponsesStall, + isResponsesFirstByteTimeout, + resolveResponsesStallWindowMs, +} from "./opencodeResponsesStall.ts"; import { isRetriableUpstreamFailure } from "./opencodeTransientFailure.ts"; import { hasProxyRefusals, @@ -534,6 +539,9 @@ export class OpencodeExecutor extends BaseExecutor { const cid = input.correlationId ? `correlationId=${input.correlationId} ` : ""; const hasProxies = this.accounts.some((a) => a.proxy !== null); + // Opt-in Responses first-byte stall guard (#13484); a no-op when the window is 0. + const stallWindowMs = resolveResponsesStallWindowMs(input.stream, this._requestFormat); + const guardStall = (r: T) => guardResponsesStall(r, stallWindowMs, input.signal); // Fast path: no multi-account proxy wiring configured → original behavior, // plus exactly ONE bounded retry when the upstream answers a 400 empty // rejection (same predicate and logging as the rotation loop). Everything @@ -546,9 +554,9 @@ export class OpencodeExecutor extends BaseExecutor { // Only pin direct egress when no such context exists; otherwise let the // ambient proxy stand instead of clobbering it with the direct sentinel. const dispatch = () => super.execute(input); - const single = (await (hasAmbientProxyContext() - ? dispatch() - : runWithDirectFetchContext(dispatch))) as HttpExecuteResult; + const single = (await guardStall( + await (hasAmbientProxyContext() ? dispatch() : runWithDirectFetchContext(dispatch)) + )) as HttpExecuteResult; if (single.response.status === 400) { let bodyText: string | null = null; try { @@ -563,7 +571,10 @@ export class OpencodeExecutor extends BaseExecutor { "OPENCODE", `${cid}upstream empty rejection on direct account (${chatcmplId}), retrying once…` ); - return this.normalizeMuseSparkResponse(input, await super.execute(input)); + return this.normalizeMuseSparkResponse( + input, + await guardStall(await super.execute(input)) + ); } log?.debug?.( "OPENCODE", @@ -603,6 +614,8 @@ export class OpencodeExecutor extends BaseExecutor { // (received refusal or refused TCP probe) are skipped. Off = plain rotation. const skipRecentlyFailed = isProxySkipRecentlyFailedEnabled(); let directTried = false; + // Stalls before the first Responses byte: one rotation, then fail fast. + let stalledAttempts = 0; for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { const isProxiedCandidate = (a: OpencodeAccountState): boolean => { @@ -676,11 +689,29 @@ export class OpencodeExecutor extends BaseExecutor { // super.execute() here always dispatches the HTTP path (opencode is an // OpenAI-compatible API, never the web/scraping bare-Response arm) — // see base.ts:290-294. - result = (await runWithProxyContext(account.proxy, () => - super.execute({ ...input, skipUpstreamRetry: true }) + result = (await guardStall( + await runWithProxyContext(account.proxy, () => + super.execute({ ...input, skipUpstreamRetry: true }) + ) )) as HttpExecuteResult; } catch (err) { const reason = err instanceof Error ? err.message : String(err); + // Stall guard: headers arrived, so the egress works — never a shared-egress + // outage; proxied and proxy-less accounts rotate alike. A client abort never rotates. + if (stallWindowMs > 0 && (isResponsesFirstByteTimeout(err) || input.signal?.aborted)) { + if (input.signal?.aborted) throw err; + this.markCooldown(account); + const stallKey = proxyKeyOf(account.proxy); + if (stallKey !== null) geoTriedProxyKeys.add(stallKey); + else directTried = true; + const rotate = ++stalledAttempts === 1; + log?.warn?.( + "OPENCODE", + `${cid}Responses stream stalled on account ${masked}, ${rotate ? "rotating to next…" : "not rotating again"} (${reason})` + ); + if (!rotate) throw err; + continue; + } // A network exception (timeout, connection refused/reset) is only // account-scoped when this account has its OWN egress (a configured // proxy) — that's the case a dead/unreachable proxy justifies rotating @@ -813,7 +844,10 @@ export class OpencodeExecutor extends BaseExecutor { } // All accounts returned 429 (or errored) — surface the last response. - return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input))); + return this.normalizeMuseSparkResponse( + input, + lastResult ?? (await guardStall(await super.execute(input))) + ); } finally { this._requestFormat = null; } diff --git a/open-sse/executors/opencodeResponsesStall.ts b/open-sse/executors/opencodeResponsesStall.ts new file mode 100644 index 0000000000..d1b1a8e87c --- /dev/null +++ b/open-sse/executors/opencodeResponsesStall.ts @@ -0,0 +1,48 @@ +/** + * opencodeResponsesStall.ts — opt-in first-byte stall guard for streamed + * Responses replies in the opencode executor (#13484). + * + * A streamed Responses reply opens with `response.created` before any + * generation, so a 2xx Responses stream that stays silent past the window is + * stalled, not thinking. Chat Completions streams are left alone: gateways may + * legitimately hold them until the answer is ready. + * + * Gated by OPENCODE_RESPONSES_STALL_ROTATION (default off). With the flag off + * the window is 0 and every guard call hands back the very same result object, + * so the stream readiness timeout stays the only bound, as before. + */ + +import { isOpencodeResponsesStallRotationEnabled } from "@/shared/utils/featureFlags"; +import { getResponsesFirstByteTimeoutMs } from "@/shared/utils/runtimeTimeouts"; +import { guardResponsesStreamFirstByte } from "../utils/firstByteWatchdog.ts"; + +export { isResponsesFirstByteTimeout } from "../utils/firstByteWatchdog.ts"; + +/** First-byte window (ms) for this request, or 0 when the guard does not apply. */ +export function resolveResponsesStallWindowMs( + stream: boolean | undefined, + requestFormat: string | null +): number { + if (!stream || requestFormat !== "openai-responses") return 0; + if (!isOpencodeResponsesStallRotationEnabled()) return 0; + return getResponsesFirstByteTimeoutMs(); +} + +/** + * Returns `result` itself when `windowMs` is 0 or it carries no 2xx body; + * otherwise resolves once the first body byte arrives, or throws + * RESPONSES_FIRST_BYTE_TIMEOUT (or the abort reason when `signal` fires). + */ +export async function guardResponsesStall( + result: T, + windowMs: number, + signal?: AbortSignal | null +): Promise { + if (windowMs <= 0 || !result || typeof result !== "object" || !("response" in result)) { + return result; + } + const response = (result as { response: Response }).response; + if (!response?.ok || !response.body) return result; + const guarded = await guardResponsesStreamFirstByte(response, windowMs, signal); + return { ...result, response: guarded }; +} diff --git a/open-sse/utils/firstByteWatchdog.ts b/open-sse/utils/firstByteWatchdog.ts new file mode 100644 index 0000000000..f58664030d --- /dev/null +++ b/open-sse/utils/firstByteWatchdog.ts @@ -0,0 +1,133 @@ +// Races a streamed body's first read() against a short deadline. A healthy body is untouched: +// the first chunk is replayed and the rest is relayed on demand, so a slow consumer never makes +// us buffer upstream bytes. A body that stays silent is cancelled and the caller gets a +// TimeoutError carrying its own code (callers pick the code so logs say which guard fired). + +export const RESPONSES_FIRST_BYTE_TIMEOUT_CODE = "RESPONSES_FIRST_BYTE_TIMEOUT"; + +export type FirstByteGuardOptions = { + timeoutMs: number; + signal?: AbortSignal | null; + code: string; + message: (timeoutMs: number) => string; +}; + +type BodyReader = ReadableStreamDefaultReader; +type FirstReadResult = ReadableStreamReadResult; + +function createTimeoutError(options: FirstByteGuardOptions): Error & { code: string } { + const err = new Error(options.message(options.timeoutMs)) as Error & { + code: string; + }; + err.name = "TimeoutError"; + err.code = options.code; + return err; +} + +function createAbortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + return err; +} + +async function raceFirstChunk( + reader: BodyReader, + options: FirstByteGuardOptions +): Promise { + const { signal } = options; + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const guards = new Promise((_, reject) => { + timer = setTimeout(() => reject(createTimeoutError(options)), options.timeoutMs); + // The timer stays referenced: the race can be the only live handle + // (direct callers, unit tests), where an unref'd timer would let the + // event loop drain before it fires. Request paths always have other + // handles, so this changes nothing there; cleared on every settle. + if (!signal) return; + if (signal.aborted) { + reject(createAbortError(signal)); + return; + } + onAbort = () => reject(createAbortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([reader.read(), guards]); + } finally { + clearTimeout(timer); + if (onAbort) signal?.removeEventListener("abort", onAbort); + } +} + +function buildOnDemandStream( + reader: BodyReader, + first: FirstReadResult +): ReadableStream { + let replayFirst = true; + return new ReadableStream({ + async pull(controller) { + if (replayFirst) { + replayFirst = false; + if (first.value) controller.enqueue(first.value); + if (first.done) controller.close(); + return; + } + try { + const { done, value } = await reader.read(); + if (done) controller.close(); + else if (value) controller.enqueue(value); + } catch (error) { + controller.error(error); + } + }, + cancel(reason) { + void reader.cancel(reason).catch(() => {}); + }, + }); +} + +export async function guardFirstByte( + response: Response, + options: FirstByteGuardOptions +): Promise { + if (!options.timeoutMs || options.timeoutMs <= 0 || !response.body) return response; + + const reader = response.body.getReader(); + let first: FirstReadResult; + try { + first = await raceFirstChunk(reader, options); + } catch (error) { + // A wedged upstream may never settle its cancel; never reintroduce the wait. + void reader.cancel(error).catch(() => {}); + throw error; + } + + return new Response(buildOnDemandStream(reader, first), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +export function isResponsesFirstByteTimeout(err: unknown): boolean { + return ( + !!err && + typeof err === "object" && + "code" in err && + (err as { code?: unknown }).code === RESPONSES_FIRST_BYTE_TIMEOUT_CODE + ); +} + +export function guardResponsesStreamFirstByte( + response: Response, + timeoutMs: number, + signal?: AbortSignal | null +): Promise { + return guardFirstByte(response, { + timeoutMs, + signal, + code: RESPONSES_FIRST_BYTE_TIMEOUT_CODE, + message: (ms) => `Responses stream produced no first body byte within ${ms}ms`, + }); +} diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index a2d4229e2c..acb62897a0 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -215,6 +215,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "OPENCODE_RESPONSES_STALL_ROTATION", + label: "OpenCode Responses Stall Rotation", + description: + "For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: RESPONSES_FIRST_BYTE_TIMEOUT_MS, default 15000). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout.", + descriptionI18nKey: "featureFlagOpencodeResponsesStallRotationDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 637de0a472..5bcb77a662 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -221,6 +221,23 @@ export function isProxyHealthBlockedResetsStreakEnabled(): boolean { } } +/** + * OpenCode Responses first-byte stall rotation (#13484). Opt-in: when off, the stream + * readiness timeout stays the only bound on a stalled Responses stream. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeResponsesStallRotationEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_RESPONSES_STALL_ROTATION"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_RESPONSES_STALL_ROTATION, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index f80219aa7a..74c7c9e96c 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -43,6 +43,11 @@ export const DEFAULT_STREAM_DISCONNECT_GRACE_PERIOD_MS = 10_000; // wreq body falls back fast instead of riding the 10-minute ceiling. Set to // 0 to disable the watchdog entirely. export const DEFAULT_TLS_FIRST_BYTE_WATCHDOG_MS = 10_000; +// A streamed Responses request opens with a lifecycle event (response.created) before any +// generation, so a 2xx Responses stream that stays silent past this window is stalled rather than +// thinking. Executors that can rotate accounts use it to move on instead of waiting for the +// readiness timeout. Set to 0 to disable. +export const DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS = 15_000; function hasEnvValue(env: EnvSource, name: string): boolean { const raw = env[name]; @@ -230,6 +235,18 @@ export function getTlsFirstByteWatchdogMs( }); } +export function getResponsesFirstByteTimeoutMs( + env: EnvSource = process.env, + logger?: TimeoutLogger +): number { + return readTimeoutMs( + env, + "RESPONSES_FIRST_BYTE_TIMEOUT_MS", + DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS, + { allowZero: true, logger } + ); +} + export function getApiBridgeTimeoutConfig( env: EnvSource = process.env, logger?: TimeoutLogger diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 8497ffaa29..c3ff5c1105 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 64; +const EXPECTED_FEATURE_FLAG_COUNT = 65; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -202,6 +202,15 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "danger"); }); + it("defines OPENCODE_RESPONSES_STALL_ROTATION as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "OPENCODE_RESPONSES_STALL_ROTATION"); + assert.ok(def, "OPENCODE_RESPONSES_STALL_ROTATION should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" diff --git a/tests/unit/first-byte-watchdog.test.ts b/tests/unit/first-byte-watchdog.test.ts new file mode 100644 index 0000000000..fef780ffbe --- /dev/null +++ b/tests/unit/first-byte-watchdog.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RESPONSES_FIRST_BYTE_TIMEOUT_CODE, + guardFirstByte, + guardResponsesStreamFirstByte, + isResponsesFirstByteTimeout, +} from "../../open-sse/utils/firstByteWatchdog.ts"; +import { + DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS, + getResponsesFirstByteTimeoutMs, +} from "../../src/shared/utils/runtimeTimeouts.ts"; + +const encoder = new TextEncoder(); + +function neverYieldingBody(): ReadableStream { + return new ReadableStream({ + pull() { + // Never enqueue, never close. + }, + }); +} + +function chunksBody(chunks: string[]): ReadableStream { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(encoder.encode(chunks[i++])); + else controller.close(); + }, + }); +} + +const OPTIONS = { + code: "TEST_CODE", + message: (ms: number) => `no first byte within ${ms}ms`, +}; + +test("a healthy body passes through byte for byte", { timeout: 5000 }, async () => { + const response = new Response(chunksBody(["data: a\n\n", "data: b\n\n"]), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + const guarded = await guardFirstByte(response, { + ...OPTIONS, + timeoutMs: 200, + }); + assert.equal(guarded.status, 200); + assert.equal(guarded.headers.get("content-type"), "text/event-stream"); + assert.equal(await guarded.text(), "data: a\n\ndata: b\n\n"); +}); + +test( + "a body that never yields throws a TimeoutError carrying the caller code", + { timeout: 5000 }, + async () => { + const response = new Response(neverYieldingBody(), { status: 200 }); + const started = Date.now(); + await assert.rejects( + guardFirstByte(response, { ...OPTIONS, timeoutMs: 60 }), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "TimeoutError"); + assert.equal((err as Error & { code?: string }).code, "TEST_CODE"); + assert.equal(err.message, "no first byte within 60ms"); + return true; + } + ); + assert.ok(Date.now() - started < 1000); + } +); + +test("timeoutMs <= 0 returns the same response untouched", async () => { + const response = new Response(neverYieldingBody(), { status: 200 }); + assert.equal(await guardFirstByte(response, { ...OPTIONS, timeoutMs: 0 }), response); +}); + +test("an immediately closed body passes", { timeout: 5000 }, async () => { + const response = new Response(chunksBody([]), { status: 200 }); + const guarded = await guardFirstByte(response, { + ...OPTIONS, + timeoutMs: 200, + }); + assert.equal(await guarded.text(), ""); +}); + +test("the passthrough reads upstream on demand, not eagerly", { timeout: 5000 }, async () => { + let upstreamPulls = 0; + const infinite = new ReadableStream({ + pull(controller) { + upstreamPulls++; + controller.enqueue(encoder.encode("x")); + }, + }); + const guarded = await guardFirstByte(new Response(infinite, { status: 200 }), { + ...OPTIONS, + timeoutMs: 200, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.ok(upstreamPulls < 10, `upstream pulled ${upstreamPulls} times without a reader`); + await guarded.body?.cancel(); +}); + +test("an aborted signal rejects without waiting for the timer", { timeout: 5000 }, async () => { + const controller = new AbortController(); + const response = new Response(neverYieldingBody(), { status: 200 }); + const started = Date.now(); + const pending = guardFirstByte(response, { + ...OPTIONS, + timeoutMs: 10_000, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 20); + await assert.rejects(pending, (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "AbortError"); + return true; + }); + assert.ok(Date.now() - started < 1000); +}); + +test( + "the Responses helper tags its timeout so callers can recognise it", + { timeout: 5000 }, + async () => { + const response = new Response(neverYieldingBody(), { status: 200 }); + await assert.rejects(guardResponsesStreamFirstByte(response, 40), (err: unknown) => { + assert.equal((err as Error & { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + assert.equal(isResponsesFirstByteTimeout(err), true); + return true; + }); + assert.equal(isResponsesFirstByteTimeout(new Error("fetch failed")), false); + } +); + +test("Responses first-byte timeout defaults to 15s, accepts 0, ignores garbage", () => { + assert.equal(DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS, 15_000); + assert.equal(getResponsesFirstByteTimeoutMs({}), 15_000); + assert.equal(getResponsesFirstByteTimeoutMs({ RESPONSES_FIRST_BYTE_TIMEOUT_MS: "0" }), 0); + assert.equal(getResponsesFirstByteTimeoutMs({ RESPONSES_FIRST_BYTE_TIMEOUT_MS: "250" }), 250); + assert.equal(getResponsesFirstByteTimeoutMs({ RESPONSES_FIRST_BYTE_TIMEOUT_MS: "abc" }), 15_000); +}); diff --git a/tests/unit/opencode-responses-first-byte-stall.test.ts b/tests/unit/opencode-responses-first-byte-stall.test.ts new file mode 100644 index 0000000000..693cd151b8 --- /dev/null +++ b/tests/unit/opencode-responses-first-byte-stall.test.ts @@ -0,0 +1,326 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { + OpencodeExecutor, + resolveOpencodeTargetFormat, +} from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { RESPONSES_FIRST_BYTE_TIMEOUT_CODE } from "../../open-sse/utils/firstByteWatchdog.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// OPENCODE_RESPONSES_STALL_ROTATION gates the whole guard (#13484 rework): the flag is read at +// the decision point through resolveFeatureFlag (DB override > env > default "false"). +const FLAG = "OPENCODE_RESPONSES_STALL_ROTATION"; + +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const RESPONSES_MODEL = "muse-spark-1.2-contributor-free"; +const CHAT_MODEL = "deepseek-v4-flash-free"; +const FPS = ["a".repeat(32), "b".repeat(32), "c".repeat(32)]; +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function proxiedCredentials(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +const directCredentials: ProviderCredentials = { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: {}, +}; + +// Several accounts, none with a dedicated proxy: every dispatch shares the default egress. +function proxylessCredentials(count: number): ProviderCredentials { + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { fingerprints: FPS.slice(0, count) }, + }; +} + +type Step = "stall" | "ok" | "429" | "throw"; + +function silentBody(): ReadableStream { + return new ReadableStream({ pull() {} }); +} + +function sseBody(): ReadableStream { + const text = + 'event: response.created\ndata: {"type":"response.created","response":{"id":"r1"}}\n\n'; + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +describe("OpencodeExecutor Responses first-byte stall", () => { + let originalFetch: typeof globalThis.fetch; + let priorTimeout: string | undefined; + let priorFlag: string | undefined; + let calls: string[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorTimeout = process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS; + priorFlag = process.env[FLAG]; + process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = "60"; + process.env[FLAG] = "true"; + calls = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorTimeout === undefined) delete process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS; + else process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = priorTimeout; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + function installFetch(plan: Step[]) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + calls.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + if (step === "throw") throw new TypeError("fetch failed"); + if (step === "429") return new Response("{}", { status: 429 }); + return new Response(step === "stall" ? silentBody() : sseBody(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof globalThis.fetch; + } + + function run( + exec: OpencodeExecutor, + model: string, + creds: ProviderCredentials, + stream = true, + signal: AbortSignal | null = null + ) { + return exec.execute({ + model, + body: { input: [{ role: "user", content: "hi" }], stream }, + stream, + signal, + credentials: creds, + log, + }) as Promise<{ response: Response }>; + } + + function cooledDown(exec: OpencodeExecutor): string[] { + const accounts = ( + exec as unknown as { + accounts: Array<{ fingerprint: string; cooldownUntil: number }>; + } + ).accounts; + return accounts.filter((a) => a.cooldownUntil > Date.now()).map((a) => a.fingerprint); + } + + it("targets the Responses API for the model under test", () => { + assert.equal(resolveOpencodeTargetFormat("opencode-zen", RESPONSES_MODEL), "openai-responses"); + assert.notEqual(resolveOpencodeTargetFormat("opencode-zen", CHAT_MODEL), "openai-responses"); + }); + + it("rotates past a silent Responses stream to a healthy account", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, [String(ports[0]), String(ports[1])]); + assert.deepEqual(cooledDown(exec), [FPS[0]]); + await result.response.body?.cancel(); + }); + + it( + "stops after the second stall without trying further accounts", + { timeout: 5000 }, + async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "stall", "ok"]); + await assert.rejects(run(exec, RESPONSES_MODEL, proxiedCredentials(3)), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + assert.equal((err as Error).name, "TimeoutError"); + return true; + }); + assert.equal(calls.length, 2, "third account and final direct call are never tried"); + assert.deepEqual(cooledDown(exec).sort(), [FPS[0], FPS[1]].sort()); + } + ); + + it("leaves a silent chat/completions stream alone", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, CHAT_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 1); + await result.response.body?.cancel(); + }); + + it("leaves non-streaming Responses requests alone", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2), false); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 1); + await result.response.body?.cancel(); + }); + + it("fails fast on the single direct account path", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const started = Date.now(); + await assert.rejects(run(exec, RESPONSES_MODEL, directCredentials), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + return true; + }); + assert.deepEqual(calls, ["direct"]); + assert.ok(Date.now() - started < 2000); + }); + + it( + "guards the final direct call after a stall and network errors", + { timeout: 5000 }, + async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "throw", "stall"]); + await assert.rejects(run(exec, RESPONSES_MODEL, proxiedCredentials(2)), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + return true; + }); + assert.equal(calls.length, 3); + } + ); + + it("does nothing when the timeout is set to 0", { timeout: 5000 }, async () => { + process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = "0"; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 1); + await result.response.body?.cancel(); + }); + + it("does not spend the stall budget on a 429", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["429", "stall", "ok"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(3)); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 3); + await result.response.body?.cancel(); + }); + it( + "flag off: a silent Responses stream is returned untouched (no guard, no rotation)", + { + timeout: 5000, + }, + async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const started = Date.now(); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, [String(ports[0])], "no second account is dispatched"); + assert.deepEqual(cooledDown(exec), [], "no account is cooled down"); + assert.ok(Date.now() - started < 1000, "the executor itself never waits on the body"); + await result.response.body?.cancel(); + } + ); + + it( + "flag off: the single direct account path never throws on a stall", + { + timeout: 5000, + }, + async () => { + process.env[FLAG] = "false"; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, RESPONSES_MODEL, directCredentials); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, ["direct"]); + await result.response.body?.cancel(); + } + ); + + it( + "rotates once across proxy-less accounts (shared egress) instead of throwing", + { + timeout: 5000, + }, + async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const result = await run(exec, RESPONSES_MODEL, proxylessCredentials(3)); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, ["direct", "direct"]); + assert.equal(cooledDown(exec).length, 1, "only the stalled account is cooled down"); + await result.response.body?.cancel(); + } + ); + + it("proxy-less fleet: the second stall fails fast", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "stall", "ok"]); + await assert.rejects(run(exec, RESPONSES_MODEL, proxylessCredentials(3)), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + return true; + }); + assert.equal(calls.length, 2); + }); + + it("a client abort during the first-byte wait never rotates", { timeout: 5000 }, async () => { + process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = "10000"; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 30); + await assert.rejects( + run(exec, RESPONSES_MODEL, proxiedCredentials(2), true, controller.signal) + ); + assert.equal(calls.length, 1, "no dispatch after the client went away"); + assert.deepEqual(cooledDown(exec), [], "an abort is not the account's fault"); + }); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 9ed43488e2..9cd490db08 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, 64); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 65); }); }); From e325888d7830372178da46361e1240f9b6b77be8 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:39:39 +0200 Subject: [PATCH 31/36] fix(sse): fail over opencode request on refused-route 403 (#13498) Behind the new `OPENCODE_USER_BLOCKED_ROTATION` flag (default off), a 403 or 451 carrying `user_blocked` on a proxied opencode account rotates at most once to the next account instead of being returned as-is. Maintainer rework before merge (kept the idea, no default behavior change): - 403 and 451 are handled by one predicate (the original returned 451 without rotation), the refused account gets a cooldown and joins the tried-set, and the response body of the attempt rotated away from is cancelled. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13498-opencode-user-blocked-rotation.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/executors/opencode.ts | 34 ++- open-sse/executors/opencodeGeoBlock.ts | 14 + open-sse/executors/opencodeResponseBody.ts | 18 ++ .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 17 ++ tests/unit/feature-flags-settings.test.ts | 11 +- .../opencode-user-blocked-predicate.test.ts | 67 +++++ .../opencode-user-blocked-rotation.test.ts | 272 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 11 files changed, 449 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/13498-opencode-user-blocked-rotation.md create mode 100644 open-sse/executors/opencodeResponseBody.ts create mode 100644 tests/unit/opencode-user-blocked-predicate.test.ts create mode 100644 tests/unit/opencode-user-blocked-rotation.test.ts diff --git a/changelog.d/fixes/13498-opencode-user-blocked-rotation.md b/changelog.d/fixes/13498-opencode-user-blocked-rotation.md new file mode 100644 index 0000000000..cd4675a75f --- /dev/null +++ b/changelog.d/fixes/13498-opencode-user-blocked-rotation.md @@ -0,0 +1 @@ +- **fix(sse):** opt-in `OPENCODE_USER_BLOCKED_ROTATION` flag (default off): an opencode 403 or 451 carrying a `user_blocked` refusal cools the refused account down and fails over to the next account at most once per request, cancelling the abandoned response body; with the flag off the refusal is returned unchanged ([#13498](https://github.com/diegosouzapw/OmniRoute/pull/13498)) — thanks @maxmad64bis diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 779325fa7d..c78cf6346e 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 -65 flags across 6 categories. **Default** is the definition default — the value +66 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) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (12) +### Network (13) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -76,6 +76,7 @@ used when neither a DB override nor an environment variable is present. | `PROXY_SKIP_RECENTLY_FAILED` | boolean | `false` | | Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default. | | `PROXY_POOL_EGRESS_OBSERVATION` | boolean | `false` | | Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h and how many connections used them. Read-only, computed from the proxy log, never used for routing. Off by default. | | `OPENCODE_RESPONSES_STALL_ROTATION` | boolean | `false` | | For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: `RESPONSES_FIRST_BYTE_TIMEOUT_MS`, default `15000`). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout. | +| `OPENCODE_USER_BLOCKED_ROTATION` | boolean | `false` | | OpenCode executor: on a 403/451 carrying a `user_blocked` refusal (not geo, not a Cloudflare fingerprint rejection), cool the refused account down and rotate to the next account at most once per request; a second refusal is returned as-is, without a success mark. Off by default: routing around an upstream user block can look like evasion and spread the flag across the fleet. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -205,7 +206,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 65 flags + // ... all 66 flags ], "summary": { "total": 56, diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 19a3d82087..ed9417cb5c 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -28,12 +28,13 @@ import { isEmptyUpstreamRejection, extractChatcmplId, } from "./accountRotation.ts"; -import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts"; +import { isOpencodeGeoBlocked, proxyKeyOf, isOpencodeUserBlocked } from "./opencodeGeoBlock.ts"; import { guardResponsesStall, isResponsesFirstByteTimeout, resolveResponsesStallWindowMs, } from "./opencodeResponsesStall.ts"; +import { discardResponseBody } from "./opencodeResponseBody.ts"; import { isRetriableUpstreamFailure } from "./opencodeTransientFailure.ts"; import { hasProxyRefusals, @@ -45,6 +46,7 @@ import { import { isNetworkRotationSharedEgressGuardEnabled, isProxySkipRecentlyFailedEnabled, + isOpencodeUserBlockedRotationEnabled, } from "@/shared/utils/featureFlags"; /** @@ -616,6 +618,11 @@ export class OpencodeExecutor extends BaseExecutor { let directTried = false; // Stalls before the first Responses byte: one rotation, then fail fast. let stalledAttempts = 0; + // A response an opt-in branch rotated away from. It stays lastResult (and + // intact) until a newer attempt replaces it, then its body is cancelled. + let abandonedResponse: Response | null = null; + // OPENCODE_USER_BLOCKED_ROTATION: rotations spent on user_blocked refusals (max 1). + let userBlockedRotations = 0; for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { const isProxiedCandidate = (a: OpencodeAccountState): boolean => { @@ -742,6 +749,8 @@ export class OpencodeExecutor extends BaseExecutor { ); continue; } + discardResponseBody(abandonedResponse); + abandonedResponse = null; lastResult = result; const status = result.response.status; @@ -799,6 +808,29 @@ export class OpencodeExecutor extends BaseExecutor { if (this.accounts.length === 1) return result; continue; } + // Opt-in (#13498): an upstream user_blocked refusal (403 or 451, same + // predicate) cools the refused account down, joins the tried-set and + // rotates at most once per request. Never a success mark. Flag off → + // falls through to the unchanged path below. + if ( + bodyText !== null && + isOpencodeUserBlocked(status, bodyText) && + isOpencodeUserBlockedRotationEnabled() + ) { + const key = proxyKeyOf(account.proxy); + if (key !== null) geoTriedProxyKeys.add(key); + else directTried = true; + this.markCooldown(account); + const rotate = userBlockedRotations === 0 && this.accounts.length > 1; + log?.warn?.( + "OPENCODE", + `${cid}user_blocked ${status} on account ${masked} (proxy ${key ?? "direct"}), ${rotate ? "rotating to next account once…" : "returning the refusal"}` + ); + if (!rotate) return result; + userBlockedRotations++; + abandonedResponse = result.response; + continue; + } } // Empty upstream rejection (malformed 400: no error field, no real diff --git a/open-sse/executors/opencodeGeoBlock.ts b/open-sse/executors/opencodeGeoBlock.ts index 71f911d2ae..dfd5cf4e56 100644 --- a/open-sse/executors/opencodeGeoBlock.ts +++ b/open-sse/executors/opencodeGeoBlock.ts @@ -11,6 +11,12 @@ // (2026-09-07 — app.log: "This model is not available in your country."); // siblings cover the same class, not the single incident. No bare "in your // country/region": location text without the full prefix is not a geo signal. +// `user_blocked` refusal (observed 2026-09-13 — upstream 403 with this token). +// Rotation on it is opt-in (OPENCODE_USER_BLOCKED_ROTATION) and bounded to one +// hop; when enabled it reuses the geo tried-set. 403 and 451 are classified the +// same way. Literal exact token only; `user-blocked` / `user blocked` are +// unobserved phrasings (fail closed). +const USER_BLOCKED_SIGNAL = "user_blocked"; const GEO_SIGNALS = [ "not available in your country", "not available in your region", @@ -49,6 +55,14 @@ export function isOpencodeGeoBlocked(status: number, bodyText: string): boolean return GEO_SIGNALS.some((signal) => lower.includes(signal)); } +/** 403 or 451 whose body carries the user_blocked token and is not a geo block or 1010 rejection. */ +export function isOpencodeUserBlocked(status: number, bodyText: string | null): boolean { + if (status !== 403 && status !== 451) return false; + const text = String(bodyText || ""); + if (isFingerprintRejection(text) || isOpencodeGeoBlocked(status, text)) return false; + return text.toLowerCase().includes(USER_BLOCKED_SIGNAL); +} + export function proxyKeyOf(proxy: { host: string; port: number } | null): string | null { if (!proxy) return null; return `${proxy.host}:${proxy.port}`; diff --git a/open-sse/executors/opencodeResponseBody.ts b/open-sse/executors/opencodeResponseBody.ts new file mode 100644 index 0000000000..63cb8244e0 --- /dev/null +++ b/open-sse/executors/opencodeResponseBody.ts @@ -0,0 +1,18 @@ +/** + * opencodeResponseBody.ts — body hygiene for the opencode rotation loop. + * + * Leaf module: zero imports. An upstream response the loop decides not to + * return (a refusal it rotates away from, a failure it pauses after) keeps its + * connection busy until the body is consumed or cancelled. Cancelling releases + * the socket right away instead of whenever the Response is garbage-collected. + */ + +/** + * Cancel a response body nobody will read. Never throws. A body that is already + * locked (a reader holds it) is left to that reader. + */ +export function discardResponseBody(response: Response | null | undefined): void { + const body = response?.body; + if (!body || body.locked) return; + void body.cancel().catch(() => undefined); +} diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index acb62897a0..6d4c3ad4f5 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -227,6 +227,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "OPENCODE_USER_BLOCKED_ROTATION", + label: "OpenCode user_blocked Rotation", + description: + "For the OpenCode executor, when an upstream answers 403 or 451 carrying a user_blocked refusal (not a geo block, not a Cloudflare fingerprint rejection), cool the refused account down and rotate to the next account at most once per request; a second refusal is returned as-is without a success mark. Off by default: routing around an upstream user block can look like evasion and spread the flag across the account fleet, so the refusal is returned unchanged unless the operator opts in.", + descriptionI18nKey: "featureFlagOpencodeUserBlockedRotationDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 5bcb77a662..8ade30cb86 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -238,6 +238,23 @@ export function isOpencodeResponsesStallRotationEnabled(): boolean { } } +/** + * OpenCode user_blocked 403/451 bounded rotation (#13498). Opt-in: when off, the refusal is + * returned unchanged exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeUserBlockedRotationEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_USER_BLOCKED_ROTATION"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_USER_BLOCKED_ROTATION, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index c3ff5c1105..2b24d0f7b7 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 65; +const EXPECTED_FEATURE_FLAG_COUNT = 66; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -211,6 +211,15 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines OPENCODE_USER_BLOCKED_ROTATION as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "OPENCODE_USER_BLOCKED_ROTATION"); + assert.ok(def, "OPENCODE_USER_BLOCKED_ROTATION should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" diff --git a/tests/unit/opencode-user-blocked-predicate.test.ts b/tests/unit/opencode-user-blocked-predicate.test.ts new file mode 100644 index 0000000000..0799a51d7f --- /dev/null +++ b/tests/unit/opencode-user-blocked-predicate.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { isOpencodeUserBlocked } from "../../open-sse/executors/opencodeGeoBlock.ts"; + +const BLOCKED_BODY = JSON.stringify({ + error: { + type: "server_error", + message: + "Error from provider (Console): Upstream request failed: [user_blocked] egress refused.", + }, +}); +const AUTH_BODY = JSON.stringify({ error: { message: "invalid api key", type: "auth_error" } }); + +describe("isOpencodeUserBlocked", () => { + it("matches 403 + user_blocked signal", () => { + assert.strictEqual(isOpencodeUserBlocked(403, BLOCKED_BODY), true); + }); + it("matches regardless of case", () => { + assert.strictEqual(isOpencodeUserBlocked(403, "[USER_BLOCKED] restricted"), true); + }); + it("classifies 451 exactly like 403 (one predicate, no status special case)", () => { + assert.strictEqual(isOpencodeUserBlocked(451, BLOCKED_BODY), true); + assert.strictEqual(isOpencodeUserBlocked(451, AUTH_BODY), false); + }); + it("leaves a geo-blocked body to the geo predicate even with the token present", () => { + const geo = JSON.stringify({ + error: { type: "RegionError", message: "not available in your country [user_blocked]" }, + }); + assert.strictEqual(isOpencodeUserBlocked(403, geo), false); + assert.strictEqual(isOpencodeUserBlocked(451, geo), false); + }); + it("rejects fingerprint 1010 even with the signal present", () => { + assert.strictEqual( + isOpencodeUserBlocked(403, `{"error_code":1010,"message":"[user_blocked] restricted"}`), + false, + "keyed 1010 = fingerprint, never rotation" + ); + assert.strictEqual( + isOpencodeUserBlocked(403, "retry after 1010 seconds, [user_blocked] restricted"), + true, + "bare 1010 is not a fingerprint token; signal still matches" + ); + }); + it("rejects fingerprint tokens even with the signal present", () => { + assert.strictEqual( + isOpencodeUserBlocked(403, "[user_blocked] browser_signature_banned"), + false + ); + assert.strictEqual(isOpencodeUserBlocked(403, "[user_blocked] fingerprint_rejection"), false); + }); + it("rejects 403 without the signal", () => { + assert.strictEqual(isOpencodeUserBlocked(403, AUTH_BODY), false); + }); + it("rejects non-403 statuses at the rotation predicate", () => { + for (const status of [200, 400, 401, 429, 500]) { + assert.strictEqual(isOpencodeUserBlocked(status, BLOCKED_BODY), false); + } + }); + it("rejects separator variants without the exact token", () => { + assert.strictEqual(isOpencodeUserBlocked(403, "user-blocked restricted"), false); + assert.strictEqual(isOpencodeUserBlocked(403, "user blocked restricted"), false); + }); + it("rejects empty and null bodies", () => { + assert.strictEqual(isOpencodeUserBlocked(403, ""), false); + assert.strictEqual(isOpencodeUserBlocked(403, null), false); + }); +}); diff --git a/tests/unit/opencode-user-blocked-rotation.test.ts b/tests/unit/opencode-user-blocked-rotation.test.ts new file mode 100644 index 0000000000..ac428b00cf --- /dev/null +++ b/tests/unit/opencode-user-blocked-rotation.test.ts @@ -0,0 +1,272 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// #13498 rework: rotation on an upstream `user_blocked` refusal is opt-in +// (OPENCODE_USER_BLOCKED_ROTATION, default off), bounded to one hop, treats 403 +// and 451 the same, cools the refused account down and cancels the body of the +// refusal it rotates away from. +const FLAG = "OPENCODE_USER_BLOCKED_ROTATION"; +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; + +const FPS = ["a", "b", "c", "d"].map((c) => c.repeat(32)); + +const BLOCKED_BODY = JSON.stringify({ + error: { + type: "server_error", + message: + "Error from provider (Console): Upstream request failed: [user_blocked] egress refused.", + }, +}); +const GEO_BODY = JSON.stringify({ + error: { type: "RegionError", message: "This model is not available in your country." }, +}); + +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function credentialsFor(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +type AccountsProbe = Array<{ + fingerprint: string; + cooldownUntil: number; + consecutiveFails: number; +}>; + +function accountsOf(exec: OpencodeExecutor): AccountsProbe { + return (exec as unknown as { accounts: AccountsProbe }).accounts; +} + +describe("OpencodeExecutor user_blocked refusal (OPENCODE_USER_BLOCKED_ROTATION)", () => { + let originalFetch: typeof globalThis.fetch; + let priorFlag: string | undefined; + let observed: string[]; + // Every upstream Response handed to the executor, in dispatch order. A body the + // loop cancelled is disturbed (bodyUsed === true) even though nobody read it. + let upstream: Response[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorFlag = process.env[FLAG]; + observed = []; + upstream = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + function installFetch(plan: Array<{ status: number; body?: string }>) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + const response = new Response(step.body ?? JSON.stringify({ ok: true }), { + status: step.status, + headers: { "Content-Type": "application/json" }, + }); + upstream.push(response); + return response; + }) as typeof globalThis.fetch; + } + + async function run(exec: OpencodeExecutor, creds: ProviderCredentials) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: creds, + log, + })) as { response: Response }; + return result.response; + } + + describe("flag off (default): the refusal is returned unchanged", () => { + for (const status of [403, 451]) { + it(`${status} user_blocked: one call, no rotation, no cooldown`, async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status, body: BLOCKED_BODY }, { status: 200 }]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, status); + assert.strictEqual(await response.text(), BLOCKED_BODY, "upstream body preserved"); + assert.strictEqual(observed.length, 1, "no failover without the flag"); + for (const a of accountsOf(exec)) assert.strictEqual(a.cooldownUntil, 0); + }); + } + }); + + describe("flag on", () => { + beforeEach(() => { + process.env[FLAG] = "true"; + }); + + for (const status of [403, 451]) { + it(`${status} user_blocked rotates once to a healthy account and cools the refused one`, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status, body: BLOCKED_BODY }, { status: 200 }]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 2); + const cooled = accountsOf(exec).filter((a) => a.cooldownUntil > Date.now()); + assert.deepStrictEqual( + cooled.map((a) => a.fingerprint), + [FPS[0]], + "only the refused account is cooled down" + ); + assert.strictEqual(upstream[0].bodyUsed, true, "the abandoned refusal body is cancelled"); + assert.strictEqual(response.bodyUsed, false, "the served body is untouched"); + await response.body?.cancel(); + }); + } + + it("rotates at most once: the second refusal is returned without trying a third account", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 403, body: BLOCKED_BODY }, + { status: 451, body: BLOCKED_BODY }, + { status: 200 }, + ]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, 451); + assert.strictEqual(await response.text(), BLOCKED_BODY, "the returned refusal is intact"); + assert.strictEqual(observed.length, 2, "bounded: one rotation only"); + const accounts = accountsOf(exec); + assert.strictEqual(accounts.filter((a) => a.cooldownUntil > Date.now()).length, 2); + for (const fp of [FPS[0], FPS[1]]) { + const account = accounts.find((a) => a.fingerprint === fp); + assert.strictEqual( + account?.consecutiveFails, + 1, + "refused accounts are never marked success" + ); + } + assert.strictEqual(upstream[0].bodyUsed, true, "the superseded refusal body is cancelled"); + }); + + it("single proxied account: the refusal comes back after one call", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 403, body: BLOCKED_BODY }, { status: 200 }]); + + const response = await run(exec, credentialsFor(1)); + + assert.strictEqual(response.status, 403); + assert.strictEqual(observed.length, 1); + assert.strictEqual(accountsOf(exec)[0].consecutiveFails, 1, "cooldown recorded, no success"); + }); + + it("the exhaustion path returns the kept refusal with its body intact", async () => { + // A refused (the one rotation), B throws a network error → the loop exhausts and + // surfaces A's kept refusal, whose body must still be readable. + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + observed.push(resolveProxyForRequest(url).proxyUrl ?? "direct"); + call++; + if (call === 1) return new Response(BLOCKED_BODY, { status: 403 }); + throw new TypeError("fetch failed"); + }) as typeof globalThis.fetch; + + const response = await run(exec, credentialsFor(2)); + + assert.strictEqual(response.status, 403); + assert.strictEqual(await response.text(), BLOCKED_BODY); + assert.strictEqual(observed.length, 2); + }); + + it("a signal-less 403/451 still passes through untouched", async () => { + for (const status of [403, 451]) { + const exec = new OpencodeExecutor("opencode-zen"); + observed = []; + installFetch([{ status, body: JSON.stringify({ error: { message: "invalid api key" } }) }]); + + const response = await run(exec, credentialsFor(2)); + + assert.strictEqual(response.status, status); + assert.strictEqual(observed.length, 1, `no retry on signal-less ${status}`); + } + }); + + it("never rotates a Cloudflare 1010 fingerprint rejection carrying the token", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 403, body: JSON.stringify({ error_code: 1010, message: "[user_blocked]" }) }, + { status: 200 }, + ]); + + const response = await run(exec, credentialsFor(2)); + + assert.strictEqual(response.status, 403); + assert.strictEqual(observed.length, 1); + }); + + it("shares the tried-set with geo rotation and does not spend its budget on geo", async () => { + // A geo-blocked (geo rotation, unbounded as before), B refused (the one user_blocked hop), + // C healthy. + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 403, body: GEO_BODY }, + { status: 403, body: BLOCKED_BODY }, + { status: 200 }, + ]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(observed, [String(ports[0]), String(ports[1]), String(ports[2])]); + await response.body?.cancel(); + }); + }); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 9cd490db08..3cd0e4d72d 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, 65); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 66); }); }); From 13f44af6e59d694e6367f24f203c76ee347cd91f Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:58:12 +0200 Subject: [PATCH 32/36] fix(sse): pause failover dispatch after repeated transient upstream failures (#13615) Behind the new `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` flag (default off), after two consecutive transient upstream failures the opencode rotation pauses before each later account (1.5s, 3s, 6s, capped at 10s per request) instead of hammering the upstream. Maintainer rework before merge (kept the idea, no default behavior change): - The pause honors the client abort signal (no dispatch after a disconnect), the failed attempt's body is cancelled before sleeping, `transientRetryDelayMs` now uses its arguments, and the sleep is injectable so the tests run without real timers. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../13615-opencode-transient-retry-delay.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/executors/opencode.ts | 34 +- .../executors/opencodeTransientFailure.ts | 73 ++++- .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 17 + tests/unit/feature-flags-settings.test.ts | 13 +- .../opencode-transient-retry-delay.test.ts | 299 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 9 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fixes/13615-opencode-transient-retry-delay.md create mode 100644 tests/unit/opencode-transient-retry-delay.test.ts diff --git a/changelog.d/fixes/13615-opencode-transient-retry-delay.md b/changelog.d/fixes/13615-opencode-transient-retry-delay.md new file mode 100644 index 0000000000..fb389806e6 --- /dev/null +++ b/changelog.d/fixes/13615-opencode-transient-retry-delay.md @@ -0,0 +1 @@ +- **fix(opencode):** opt-in `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` flag (default off): once two consecutive opencode accounts fail with a transient upstream error, the rotation pauses before the next account (1.5 s doubling, capped at 6 s per pause and 10 s per request), releases the failed response body first and stops dispatching if the client disconnects during the pause; with the flag off failover stays immediate ([#13615](https://github.com/diegosouzapw/OmniRoute/pull/13615)) — thanks @maxmad64bis diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index c78cf6346e..d138d762af 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 -66 flags across 6 categories. **Default** is the definition default — the value +67 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) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (13) +### Network (14) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -77,6 +77,7 @@ used when neither a DB override nor an environment variable is present. | `PROXY_POOL_EGRESS_OBSERVATION` | boolean | `false` | | Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h and how many connections used them. Read-only, computed from the proxy log, never used for routing. Off by default. | | `OPENCODE_RESPONSES_STALL_ROTATION` | boolean | `false` | | For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: `RESPONSES_FIRST_BYTE_TIMEOUT_MS`, default `15000`). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout. | | `OPENCODE_USER_BLOCKED_ROTATION` | boolean | `false` | | OpenCode executor: on a 403/451 carrying a `user_blocked` refusal (not geo, not a Cloudflare fingerprint rejection), cool the refused account down and rotate to the next account at most once per request; a second refusal is returned as-is, without a success mark. Off by default: routing around an upstream user block can look like evasion and spread the flag across the fleet. | +| `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` | boolean | `false` | | OpenCode rotation: after two consecutive transient upstream failures (5xx or an empty 400), pause before the next account — 1.5s doubling per further failure, capped at 6s per pause and 10s per request, skipped on client disconnect; the failed body is released before waiting. Off by default: failover stays immediate. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -206,7 +207,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 66 flags + // ... all 67 flags ], "summary": { "total": 56, diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index ed9417cb5c..754f0f284e 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -35,7 +35,12 @@ import { resolveResponsesStallWindowMs, } from "./opencodeResponsesStall.ts"; import { discardResponseBody } from "./opencodeResponseBody.ts"; -import { isRetriableUpstreamFailure } from "./opencodeTransientFailure.ts"; +import { + isRetriableUpstreamFailure, + releaseResponseBody, + sleepAbortable, + transientRetryDelayMs, +} from "./opencodeTransientFailure.ts"; import { hasProxyRefusals, isProxyAvoided, @@ -47,6 +52,7 @@ import { isNetworkRotationSharedEgressGuardEnabled, isProxySkipRecentlyFailedEnabled, isOpencodeUserBlockedRotationEnabled, + isOpencodeTransientFailoverBackoffEnabled, } from "@/shared/utils/featureFlags"; /** @@ -311,6 +317,10 @@ export class OpencodeExecutor extends BaseExecutor { // pickRotatableAccount(), which needs a plain `{ nextAccountIdx }` shape — // TS's private-member nominal check rejects `this` there otherwise. nextAccountIdx = 0; + // Sleep used by the opt-in transient failover pause (#13615). Not `private`: + // tests swap in a recording fake instead of waiting on real timers. + transientPauseSleep: (ms: number, signal?: AbortSignal | null) => Promise = + sleepAbortable; constructor(provider: string) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); @@ -623,6 +633,10 @@ export class OpencodeExecutor extends BaseExecutor { let abandonedResponse: Response | null = null; // OPENCODE_USER_BLOCKED_ROTATION: rotations spent on user_blocked refusals (max 1). let userBlockedRotations = 0; + // Consecutive transient failures (5xx / empty 400) and the pause time spent on + // them this request — only acted on when OPENCODE_TRANSIENT_FAILOVER_BACKOFF is on. + let transientStreak = 0; + let transientPausedMs = 0; for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { const isProxiedCandidate = (a: OpencodeAccountState): boolean => { @@ -676,6 +690,19 @@ export class OpencodeExecutor extends BaseExecutor { continue; } + // Opt-in (#13615): after repeated transient failures, release the failed body + // and wait (bounded) before the next account; a client abort stops the loop. + const pauseMs = transientRetryDelayMs(transientStreak, transientPausedMs); + if (pauseMs > 0 && lastResult !== null && isOpencodeTransientFailoverBackoffEnabled()) { + lastResult = { ...lastResult, response: releaseResponseBody(lastResult.response) }; + transientPausedMs += pauseMs; + log?.info?.( + "OPENCODE", + `${cid}${transientStreak} transient failures, pausing ${pauseMs}ms` + ); + if (!(await this.transientPauseSleep(pauseMs, input.signal))) break; + } + // #5217 (Gap 2): promoted debug→info so the per-request account/proxy // rotation selection is visible in the Console log view at the default // APP_LOG_LEVEL=info (users could not see which account/proxy was used). @@ -719,6 +746,7 @@ export class OpencodeExecutor extends BaseExecutor { if (!rotate) throw err; continue; } + transientStreak = 0; // A network exception (timeout, connection refused/reset) is only // account-scoped when this account has its OWN egress (a configured // proxy) — that's the case a dead/unreachable proxy justifies rotating @@ -752,6 +780,8 @@ export class OpencodeExecutor extends BaseExecutor { discardResponseBody(abandonedResponse); abandonedResponse = null; lastResult = result; + const priorTransientStreak = transientStreak; + transientStreak = 0; const status = result.response.status; if (status === 429) { @@ -774,6 +804,7 @@ export class OpencodeExecutor extends BaseExecutor { const key = proxyKeyOf(account.proxy); if (key !== null) geoTriedProxyKeys.add(key); else directTried = true; + transientStreak = priorTransientStreak + 1; log?.warn?.( "OPENCODE", `${cid}transient upstream ${status} on account ${masked} (proxy ${key ?? "direct"}), rotating to next…` @@ -849,6 +880,7 @@ export class OpencodeExecutor extends BaseExecutor { } if (bodyText !== null && isRetriableUpstreamFailure(400, bodyText)) { const chatcmplId = extractChatcmplId(bodyText); + transientStreak = priorTransientStreak + 1; log?.warn?.( "OPENCODE", `${cid}upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…` diff --git a/open-sse/executors/opencodeTransientFailure.ts b/open-sse/executors/opencodeTransientFailure.ts index 9af52a1fa4..320e5de8be 100644 --- a/open-sse/executors/opencodeTransientFailure.ts +++ b/open-sse/executors/opencodeTransientFailure.ts @@ -2,12 +2,14 @@ * opencodeTransientFailure.ts — retriable-upstream predicate for the opencode * executor loop. * - * Leaf module: one internal import only (isEmptyUpstreamRejection, same - * executors layer — no registry, no DB). 5xx short-circuits on status alone; - * the 400 arm delegates to the existing empty-rejection classifier. + * Leaf module: internal imports from the same executors layer only + * (isEmptyUpstreamRejection, discardResponseBody — no registry, no DB). 5xx + * short-circuits on status alone; the 400 arm delegates to the existing + * empty-rejection classifier. */ import { isEmptyUpstreamRejection } from "./accountRotation.ts"; +import { discardResponseBody } from "./opencodeResponseBody.ts"; export function isRetriableUpstreamFailure(status: number, bodyText?: string): boolean { if (status >= 500 && status < 600) return true; @@ -15,3 +17,68 @@ export function isRetriableUpstreamFailure(status: number, bodyText?: string): b if (typeof bodyText !== "string" || bodyText === "") return false; return isEmptyUpstreamRejection(status, bodyText); } + +// ── Failover pause after repeated transient failures (#13615, opt-in) ────── +// Gated by OPENCODE_TRANSIENT_FAILOVER_BACKOFF (default off). The first retry +// after a transient failure stays immediate (a distinct egress already guards +// a one-off flap); from the second consecutive transient failure on, the loop +// waits before the next account so a briefly overloaded upstream can recover. + +/** Consecutive transient failures before the first pause. */ +export const TRANSIENT_PAUSE_STREAK = 2; +/** First pause, same magnitude as BaseExecutor.WAF_RETRY_CONFIG.delayMs. */ +export const TRANSIENT_RETRY_BASE_DELAY_MS = 1500; +/** Upper bound of a single pause. */ +export const TRANSIENT_RETRY_MAX_DELAY_MS = 6000; +/** Upper bound of all pauses in one request. */ +export const TRANSIENT_RETRY_TOTAL_BUDGET_MS = 10_000; + +/** + * Pause before the next dispatch after `consecutiveFailures` transient failures + * in a row, given `pausedMs` already spent this request. 0 means dispatch now. + * Doubles per further failure (1.5s, 3s, 6s, 6s…) and never exceeds the + * per-pause cap or what is left of the per-request budget. + */ +export function transientRetryDelayMs(consecutiveFailures: number, pausedMs = 0): number { + if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < TRANSIENT_PAUSE_STREAK) { + return 0; + } + const step = Math.min(consecutiveFailures - TRANSIENT_PAUSE_STREAK, 16); + const delay = Math.min(TRANSIENT_RETRY_BASE_DELAY_MS * 2 ** step, TRANSIENT_RETRY_MAX_DELAY_MS); + const left = TRANSIENT_RETRY_TOTAL_BUDGET_MS - Math.max(0, pausedMs); + return Math.max(0, Math.min(delay, left)); +} + +/** + * Sleep that resolves `false` as soon as `signal` aborts (or immediately when it + * already has), `true` once `ms` elapsed. The listener and timer are always + * released. + */ +export function sleepAbortable(ms: number, signal?: AbortSignal | null): Promise { + if (signal?.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timer); + resolve(false); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(true); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Cancel a failed response's body before a pause and return a body-less copy + * that keeps its status, status text and headers (what the exhaustion path may + * still surface once the loop ends). + */ +export function releaseResponseBody(response: Response): Response { + discardResponseBody(response); + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 6d4c3ad4f5..994d7607a8 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -239,6 +239,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "OPENCODE_TRANSIENT_FAILOVER_BACKOFF", + label: "OpenCode Transient Failover Backoff", + description: + "For the OpenCode multi-account rotation, pause before dispatching to the next account once two consecutive attempts failed with a transient upstream error (5xx or an empty 400 rejection). The pause starts at 1.5s, doubles per further consecutive failure, is capped at 6s per pause and 10s per request, is skipped when the client disconnects, and the failed response body is released before waiting. Off by default: failover stays immediate.", + descriptionI18nKey: "featureFlagOpencodeTransientFailoverBackoffDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 8ade30cb86..1b52df7cc1 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -255,6 +255,23 @@ export function isOpencodeUserBlockedRotationEnabled(): boolean { } } +/** + * OpenCode transient-failure failover pause (#13615). Opt-in: when off, failover to the next + * account stays immediate exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeTransientFailoverBackoffEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_TRANSIENT_FAILOVER_BACKOFF"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_TRANSIENT_FAILOVER_BACKOFF, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 2b24d0f7b7..3a52a3b3f6 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 66; +const EXPECTED_FEATURE_FLAG_COUNT = 67; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -220,6 +220,17 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines OPENCODE_TRANSIENT_FAILOVER_BACKOFF as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "OPENCODE_TRANSIENT_FAILOVER_BACKOFF" + ); + assert.ok(def, "OPENCODE_TRANSIENT_FAILOVER_BACKOFF should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" diff --git a/tests/unit/opencode-transient-retry-delay.test.ts b/tests/unit/opencode-transient-retry-delay.test.ts new file mode 100644 index 0000000000..c4bfb76a78 --- /dev/null +++ b/tests/unit/opencode-transient-retry-delay.test.ts @@ -0,0 +1,299 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { + TRANSIENT_RETRY_BASE_DELAY_MS, + TRANSIENT_RETRY_MAX_DELAY_MS, + TRANSIENT_RETRY_TOTAL_BUDGET_MS, + transientRetryDelayMs, + sleepAbortable, +} from "../../open-sse/executors/opencodeTransientFailure.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// #13615 rework: the failover pause is opt-in (OPENCODE_TRANSIENT_FAILOVER_BACKOFF, +// default off), bounded (per-pause cap + per-request budget), honors the client +// abort signal and releases the failed body before waiting. The executor's sleep +// is injected, so no test waits on a real 1.5s timer. +const FLAG = "OPENCODE_TRANSIENT_FAILOVER_BACKOFF"; +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FPS = ["a", "b", "c", "d", "e", "f", "g"].map((c) => c.repeat(32)); + +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function credentialsFor(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +const GEO_BODY = JSON.stringify({ + error: { type: "RegionError", message: "This model is not available in your country." }, +}); +// Empty upstream rejection: 400 without an error field (see isEmptyUpstreamRejection). +const EMPTY_BODY = + '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}'; + +describe("transient failover pause helpers", () => { + it("uses its argument: nothing before the second failure, then bounded doubling", () => { + assert.strictEqual(TRANSIENT_RETRY_BASE_DELAY_MS, BaseExecutor.WAF_RETRY_CONFIG.delayMs); + assert.strictEqual(transientRetryDelayMs(0), 0); + assert.strictEqual(transientRetryDelayMs(1), 0); + assert.strictEqual(transientRetryDelayMs(2), 1500); + assert.strictEqual(transientRetryDelayMs(3), 3000); + assert.strictEqual(transientRetryDelayMs(4), TRANSIENT_RETRY_MAX_DELAY_MS); + assert.strictEqual(transientRetryDelayMs(50), TRANSIENT_RETRY_MAX_DELAY_MS); + assert.strictEqual(transientRetryDelayMs(Number.NaN), 0); + }); + + it("never exceeds what is left of the per-request budget", () => { + assert.strictEqual(transientRetryDelayMs(4, TRANSIENT_RETRY_TOTAL_BUDGET_MS - 1000), 1000); + assert.strictEqual(transientRetryDelayMs(4, TRANSIENT_RETRY_TOTAL_BUDGET_MS), 0); + assert.strictEqual(transientRetryDelayMs(2, TRANSIENT_RETRY_TOTAL_BUDGET_MS + 5), 0); + }); + + it("sleepAbortable resolves true after the delay and false on abort", async () => { + assert.strictEqual(await sleepAbortable(5), true); + assert.strictEqual(await sleepAbortable(5, new AbortController().signal), true); + const controller = new AbortController(); + const pending = sleepAbortable(60_000, controller.signal); + controller.abort(); + assert.strictEqual(await pending, false); + const aborted = new AbortController(); + aborted.abort(); + assert.strictEqual(await sleepAbortable(60_000, aborted.signal), false); + }); +}); + +describe("opencode rotation with OPENCODE_TRANSIENT_FAILOVER_BACKOFF", () => { + let originalFetch: typeof globalThis.fetch; + let priorFlag: string | undefined; + let observed: string[]; + let upstream: Response[]; + let sleeps: number[]; + // Filled per test: what each dispatched attempt answers. + let events: string[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorFlag = process.env[FLAG]; + process.env[FLAG] = "true"; + observed = []; + upstream = []; + sleeps = []; + events = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + function installFetch(plan: Array<{ status: number; body?: string }>) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + events.push(`dispatch:${step.status}`); + const response = new Response(step.body ?? JSON.stringify({ ok: step.status === 200 }), { + status: step.status, + headers: { "Content-Type": "application/json", "x-upstream-call": String(call) }, + }); + upstream.push(response); + return response; + }) as typeof globalThis.fetch; + } + + function newExecutor(onSleep?: (ms: number) => boolean): OpencodeExecutor { + const exec = new OpencodeExecutor("opencode-zen"); + exec.transientPauseSleep = async (ms, signal) => { + sleeps.push(ms); + events.push(`sleep:${ms}`); + if (signal?.aborted) return false; + return onSleep ? onSleep(ms) : true; + }; + return exec; + } + + async function run(exec: OpencodeExecutor, count: number, signal: AbortSignal | null = null) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal, + credentials: credentialsFor(count), + log, + })) as { response: Response }; + return result.response; + } + + it("flag off: failover stays immediate even after a long transient streak", async () => { + delete process.env[FLAG]; + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 502 }, { status: 503 }, { status: 200 }]); + + const response = await run(exec, 4); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 4); + assert.deepStrictEqual(sleeps, [], "no pause without the flag"); + assert.strictEqual(upstream[0].bodyUsed, false, "flag off never touches failed bodies"); + await response.body?.cancel(); + }); + + it("the first retry after one transient failure is immediate", async () => { + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(sleeps, []); + await response.body?.cancel(); + }); + + it("pauses before the third account, after releasing the failed body", async () => { + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 500 }, { status: 200 }]); + + const response = await run(exec, 3); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(events, ["dispatch:500", "dispatch:500", "sleep:1500", "dispatch:200"]); + assert.strictEqual(upstream[1].bodyUsed, true, "the failed body is cancelled before sleeping"); + await response.body?.cancel(); + }); + + it("backs off with its argument, bounded by the per-request budget", async () => { + const exec = newExecutor(); + installFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 200 }, + ]); + + const response = await run(exec, 5); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(sleeps, [1500, 3000, 5500], "1.5s, 3s, then the 10s budget remainder"); + await response.body?.cancel(); + }); + + it("stops pausing once the per-request budget is spent", async () => { + const exec = newExecutor(); + installFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 200 }, + ]); + + const response = await run(exec, 7); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 7); + assert.strictEqual( + sleeps.reduce((a, b) => a + b, 0), + TRANSIENT_RETRY_TOTAL_BUDGET_MS, + "total pause time is bounded" + ); + await response.body?.cancel(); + }); + + it("a mixed streak (500 then empty 400) pauses; a 429 or geo 403 resets it", async () => { + const mixed = newExecutor(); + installFetch([{ status: 500 }, { status: 400, body: EMPTY_BODY }, { status: 200 }]); + const mixedResponse = await run(mixed, 3); + assert.strictEqual(mixedResponse.status, 200); + assert.deepStrictEqual(sleeps, [1500]); + await mixedResponse.body?.cancel(); + + for (const breaker of [{ status: 429 }, { status: 403, body: GEO_BODY }]) { + sleeps = []; + events = []; + const exec = newExecutor(); + installFetch([{ status: 500 }, breaker, { status: 500 }, { status: 200 }]); + const response = await run(exec, 4); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(sleeps, [], `${breaker.status} breaks the streak`); + await response.body?.cancel(); + } + }); + + it("a client abort during the pause dispatches nothing more", async () => { + const controller = new AbortController(); + const exec = newExecutor(() => { + controller.abort(); + return false; + }); + installFetch([{ status: 500 }, { status: 500 }, { status: 200 }]); + + const response = await run(exec, 3, controller.signal); + + assert.strictEqual(observed.length, 2, "no third dispatch after the abort"); + assert.strictEqual(response.status, 500, "the last failure status is surfaced"); + assert.strictEqual(response.headers.get("x-upstream-call"), "2", "its headers are kept"); + }); + + it("an already-aborted signal skips the pause and the dispatch", async () => { + const controller = new AbortController(); + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 500 }, { status: 200 }]); + let calls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (...args: Parameters) => { + calls++; + const response = await realFetch(...args); + if (calls === 2) controller.abort(); + return response; + }) as typeof globalThis.fetch; + + const response = await run(exec, 3, controller.signal); + + assert.strictEqual(calls, 2); + assert.strictEqual(response.status, 500); + }); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 3cd0e4d72d..e68a020654 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, 66); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 67); }); }); From 94d27e44fe73ad23a7607604bdd982e6d5feeb4a Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:19:47 +0200 Subject: [PATCH 33/36] fix(providers): stop parking Mistral connections on a bare 401 with no clear auth failure (#13609) Behind the new `MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT` flag (default off), a bare Mistral 401 (`{"detail":"Unauthorized"}`, identical for a revoked key and an exhausted quota) gets a retryable cooldown instead of parking the connection as `expired`; after three soft strikes within an hour the next bare 401 parks it, so revocation still converges. Maintainer rework before merge (kept the idea, no default behavior change): - The predicate is shared with the connection-test module instead of duplicated; the squeezed 139-char line that dodged the file-size gate is formatted normally and the growth is rebaselined honestly with an annotation. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- .../fixes/13609-mistral-401-ambiguous-auth.md | 1 + config/quality/file-size-baseline.json | 6 +- docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/services/accountFallback.ts | 14 ++ .../accountFallback/mistralAmbiguousAuth.ts | 58 +++++ .../[id]/test/mistralAmbiguousAuth.ts | 12 +- .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 17 ++ src/sse/services/auth.ts | 5 +- src/sse/services/authTerminalStatus.ts | 12 +- tests/unit/feature-flags-settings.test.ts | 13 +- .../provider-401-ambiguous-runtime.test.ts | 227 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 13 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fixes/13609-mistral-401-ambiguous-auth.md create mode 100644 open-sse/services/accountFallback/mistralAmbiguousAuth.ts create mode 100644 tests/unit/provider-401-ambiguous-runtime.test.ts diff --git a/changelog.d/fixes/13609-mistral-401-ambiguous-auth.md b/changelog.d/fixes/13609-mistral-401-ambiguous-auth.md new file mode 100644 index 0000000000..58d4b00d59 --- /dev/null +++ b/changelog.d/fixes/13609-mistral-401-ambiguous-auth.md @@ -0,0 +1 @@ +- **fix(providers):** opt-in `MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT` flag (default off): a bare Mistral 401 with no explicit auth signal (identical for a revoked key and an exhausted quota) cools the connection down instead of parking it as expired, at most 3 times per hour per connection before it parks, so a revoked key still converges; the ambiguity check is now one implementation shared by the connection test and the runtime ([#13609](https://github.com/diegosouzapw/OmniRoute/pull/13609)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 93eb1f9d32..3de72e8e61 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_15_13609_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/accountFallback.ts->2507. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13602_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1214. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_15_13580_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1202. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", "_rebaseline_2026_09_13_13581_pool_egress_observation": "PR #13581 own growth: src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx 1475->1477 (+2 = the PoolEgressObservation import and its one-line mount under the pool members label). The observation itself lives outside the frozen file, all under cap: PoolEgressObservation.tsx, the dedicated GET /api/settings/proxies/pool/egress-observation route, src/lib/proxyPoolEgressObservation.ts and getPoolEgressObservation in src/lib/db/proxyLogs.ts. Only the mount point is irreducible. Covered by tests/unit/proxy-pool-egress-observation.test.ts, tests/unit/proxy-pool-egress-observation-route.test.ts and tests/unit/ui/PoolEgressObservation.test.tsx.", @@ -345,6 +346,7 @@ "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", "frozen": { "src/sse/handlers/chatHelpers.ts": 1214, + "_rebaseline_2026_09_15_13609_mistral_ambiguous_401": "PR #13609 rework (maxmad64bis, bare Mistral 401 soft lockout behind MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT, default off). open-sse/services/accountFallback.ts 2469->2501 (+32): +14 are the change itself (shared-predicate + flag imports, the documented ambiguousAuth field on the checkFallbackError return type, and the flag-gated 401 branch formatted normally instead of the PR's 139-char squeezed configuredRule line); +18 are the lint-staged prettier pass normalizing lines that were already unformatted on the release tip (multi-import, ISO_RETRY_RE, two regex arrays, persistAntigravityFamilyCooldownIfQuota call, applyErrorState guard, trailing commas) — pure formatting, no logic. src/sse/services/auth.ts 3556->3557 (+1): markAccountUnavailable passes connectionId to resolveTerminalConnectionStatus so the soft-strike bound is per connection. The predicate and strike tracker live in the leaf open-sse/services/accountFallback/mistralAmbiguousAuth.ts (under cap). Covered by tests/unit/provider-401-ambiguous-runtime.test.ts (flag off/on, end-to-end through markAccountUnavailable).", "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, normalized.includes(signal)); +} + +const strikes = new Map(); + +/** + * Record one ambiguous bare 401 for `connectionId` and say whether it may still + * be softened (true) or must park the connection (false). Crossing the bound + * clears the entry, so a re-authenticated connection starts a fresh count. + */ +export function takeMistralAmbiguous401SoftStrike(connectionId: string, now = Date.now()): boolean { + const entry = strikes.get(connectionId); + const current = + entry && now - entry.firstAt < MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS + ? entry + : { count: 0, firstAt: now }; + current.count += 1; + if (current.count > MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES) { + strikes.delete(connectionId); + return false; + } + strikes.set(connectionId, current); + return true; +} + +/** Test hook: forget every recorded strike. */ +export function resetMistralAmbiguous401Strikes(): void { + strikes.clear(); +} diff --git a/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts b/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts index 8f47be63a5..9f997ea0a9 100644 --- a/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts +++ b/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts @@ -1,3 +1,5 @@ +import { isMistralAmbiguous401 } from "@omniroute/open-sse/services/accountFallback/mistralAmbiguousAuth.ts"; + /** * #7638: Mistral's quota-exhausted response is `401 {"detail":"Unauthorized"}` — byte-identical * to a genuinely revoked key. Unlike other providers, a bare Mistral 401 with no auth-specific @@ -23,16 +25,6 @@ export interface ClassifyFailureArgs { provider?: string; } -function isMistralAmbiguous401(provider: string | undefined, normalized: string): boolean { - if (provider !== "mistral") return false; - const hasAuthSignal = - normalized.includes("invalid api key") || - normalized.includes("token invalid") || - normalized.includes("revoked") || - normalized.includes("access denied"); - return !hasAuthSignal; -} - /** Decides the diagnosis for a 401/403 status: ambiguous (Mistral-only) or the generic auth error. */ export function classifyAmbiguousOrAuthError( provider: string | undefined, diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 994d7607a8..6b87df1e11 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -702,6 +702,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT", + label: "Mistral Ambiguous 401 Soft Lockout", + description: + 'A bare Mistral 401 ({"detail":"Unauthorized"}, no explicit auth signal) is byte-identical for a revoked key and for exhausted quota. When enabled, such a 401 cools the connection down instead of parking it as expired, up to 3 times within an hour; the next one still parks it as expired, so a revoked key converges. Off by default: every bare Mistral 401 parks the connection as expired, as before.', + descriptionI18nKey: "featureFlagMistralAmbiguous401SoftLockoutDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, // ──────────────── CLI (5) ──────────────── { diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 1b52df7cc1..e479e72a61 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -272,6 +272,23 @@ export function isOpencodeTransientFailoverBackoffEnabled(): boolean { } } +/** + * Mistral bare-401 bounded soft lockout (#13609). Opt-in: when off, a bare Mistral 401 parks + * the connection as expired exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isMistralAmbiguous401SoftLockoutEnabled(): boolean { + try { + return isFeatureFlagEnabled("MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index a9be1beefa..f1f5cb9dfe 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -3157,11 +3157,12 @@ export async function markAccountUnavailable( let terminalStatus = resolveTerminalConnectionStatus( status, - result as { permanent?: boolean; creditsExhausted?: boolean }, + result as { permanent?: boolean; creditsExhausted?: boolean; ambiguousAuth?: boolean }, providerErrorType, provider, isPerModelQuotaProvider, - errorText + errorText, + connectionId ); // A still-valid access token after a successful refresh is not "expired". // A follow-up 401 (timeout, hop, race) must cooldown, not park the account. diff --git a/src/sse/services/authTerminalStatus.ts b/src/sse/services/authTerminalStatus.ts index b8afed537b..6ef2f50f8b 100644 --- a/src/sse/services/authTerminalStatus.ts +++ b/src/sse/services/authTerminalStatus.ts @@ -1,5 +1,6 @@ import { PROVIDER_ERROR_TYPES } from "@omniroute/open-sse/services/errorClassifier.ts"; import { isCreditsExhausted } from "@omniroute/open-sse/services/accountFallback.ts"; +import { takeMistralAmbiguous401SoftStrike } from "@omniroute/open-sse/services/accountFallback/mistralAmbiguousAuth.ts"; import { resolveProviderId, WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; // #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser @@ -71,11 +72,12 @@ function isExpiredAuthFailure( export function resolveTerminalConnectionStatus( status: number, - result: { permanent?: boolean; creditsExhausted?: boolean }, + result: { permanent?: boolean; creditsExhausted?: boolean; ambiguousAuth?: boolean }, providerErrorType: string | null = null, provider: string | null = null, isPerModelQuotaProvider = false, - errorText: string = "" + errorText: string = "", + connectionId: string | null = null ): string | null { if (shouldParkCreditsExhausted(status, result, isPerModelQuotaProvider, errorText)) { return "credits_exhausted"; @@ -87,6 +89,12 @@ export function resolveTerminalConnectionStatus( return "banned"; } if (isExpiredAuthFailure(status, providerErrorType, provider)) { + // #13609: checkFallbackError only sets ambiguousAuth for a bare Mistral 401 + // with MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT on. Bounded per connection: past + // the strike limit the connection parks as expired like any other 401. + if (status === 401 && result.ambiguousAuth && connectionId) { + if (takeMistralAmbiguous401SoftStrike(connectionId)) return null; + } return "expired"; } return null; diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 3a52a3b3f6..e725b8b546 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 67; +const EXPECTED_FEATURE_FLAG_COUNT = 68; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -231,6 +231,17 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT as an opt-in runtime boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT" + ); + assert.ok(def, "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT should exist"); + assert.strictEqual(def.category, "runtime"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" diff --git a/tests/unit/provider-401-ambiguous-runtime.test.ts b/tests/unit/provider-401-ambiguous-runtime.test.ts new file mode 100644 index 0000000000..15422df01c --- /dev/null +++ b/tests/unit/provider-401-ambiguous-runtime.test.ts @@ -0,0 +1,227 @@ +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"; + +// #13609 rework: a bare Mistral 401 is byte-identical for a revoked key and an +// exhausted quota (#7638). With MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT on, it cools the +// connection down instead of parking it as expired — at most 3 times per hour per +// connection, then it parks, so a revoked key still converges. Flag off (default): +// every bare Mistral 401 parks the connection as before. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13609-mistral-401-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const FLAG = "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT"; +const BARE = '{"detail":"Unauthorized"}'; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { resolveTerminalConnectionStatus } = + await import("../../src/sse/services/authTerminalStatus.ts"); +const { classifyProviderError, PROVIDER_ERROR_TYPES } = + await import("../../open-sse/services/errorClassifier.ts"); +const { setOperatorProviderErrorRules } = + await import("../../open-sse/config/providerErrorRules.ts"); +const { + isMistralAmbiguous401, + takeMistralAmbiguous401SoftStrike, + resetMistralAmbiguous401Strikes, + MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES, + MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS, +} = await import("../../open-sse/services/accountFallback/mistralAmbiguousAuth.ts"); +const { classifyFailure } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +const priorFlag = process.env[FLAG]; + +function setFlag(value: string | undefined) { + if (value === undefined) delete process.env[FLAG]; + else process.env[FLAG] = value; +} + +test.beforeEach(() => { + resetMistralAmbiguous401Strikes(); + setFlag(undefined); +}); + +test.after(() => { + setFlag(priorFlag); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function bareMistral401() { + return checkFallbackError(401, BARE, 0, null, "mistral", null, null, null); +} + +test("one shared predicate: the connection-test diagnosis and the runtime agree", () => { + assert.equal(isMistralAmbiguous401("mistral", BARE), true); + assert.equal(isMistralAmbiguous401("mistral", "Invalid API key"), false); + assert.equal(isMistralAmbiguous401("openai", BARE), false); + assert.equal( + classifyFailure({ error: BARE, statusCode: 401, provider: "mistral" }).type, + "upstream_ambiguous_auth_or_quota" + ); + assert.equal( + classifyFailure({ error: "Token invalid", statusCode: 401, provider: "mistral" }).type, + "upstream_auth_error" + ); +}); + +test("flag off: a bare Mistral 401 stays an auth_error and resolves expired", () => { + const r = bareMistral401(); + assert.equal(r.reason, "auth_error"); + assert.equal(r.ambiguousAuth, undefined); + const type = classifyProviderError(401, BARE, "mistral"); + assert.equal( + resolveTerminalConnectionStatus(401, r, type, "mistral", false, BARE, "c1"), + "expired" + ); +}); + +test("flag on: a bare Mistral 401 backs off instead of asserting an auth failure", () => { + setFlag("true"); + const r = bareMistral401(); + assert.notEqual(r.reason, "auth_error"); + assert.equal(r.ambiguousAuth, true); + assert.equal(r.shouldFallback, true); + assert.ok(!r.permanent); + assert.ok(r.cooldownMs > 0, "a real cooldown, not an immediate reselect"); +}); + +test("flag on: explicit auth signals, other providers and operator rules are unchanged", () => { + setFlag("true"); + for (const body of ["Invalid API key", "token invalid", "revoked", "access denied"]) { + const r = checkFallbackError(401, body, 0, null, "mistral", null, null, null); + assert.equal(r.reason, "auth_error", body); + assert.equal(r.ambiguousAuth, undefined, body); + } + assert.equal( + checkFallbackError(401, BARE, 0, null, "openai", null, null, null).reason, + "auth_error" + ); + setOperatorProviderErrorRules({ + mistral: [{ status: 401, match: "unauthorized", scope: "connection", cooldownMs: 99999 }], + }); + try { + const r = bareMistral401(); + assert.equal(r.reason, "quota_exhausted"); + assert.equal(r.cooldownMs, 99999); + assert.equal(r.ambiguousAuth, undefined); + } finally { + setOperatorProviderErrorRules({}); + } +}); + +test("strike bound: 3 soft strikes per window, the 4th parks and restarts the count", () => { + const t0 = 1_000_000; + for (let i = 1; i <= MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES; i++) { + assert.equal(takeMistralAmbiguous401SoftStrike("conn", t0 + i), true, `strike ${i}`); + } + assert.equal(takeMistralAmbiguous401SoftStrike("conn", t0 + 10), false, "bound reached"); + assert.equal(takeMistralAmbiguous401SoftStrike("conn", t0 + 11), true, "fresh count after park"); + assert.equal(takeMistralAmbiguous401SoftStrike("other", t0 + 12), true, "per connection"); + // Strikes older than the window do not accumulate. + resetMistralAmbiguous401Strikes(); + for (let i = 0; i < MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES; i++) { + takeMistralAmbiguous401SoftStrike("slow", t0); + } + assert.equal( + takeMistralAmbiguous401SoftStrike("slow", t0 + MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS), + true + ); +}); + +test("resolveTerminalConnectionStatus ignores ambiguousAuth without a connection id or for other types", () => { + const r = { ambiguousAuth: true }; + assert.equal( + resolveTerminalConnectionStatus( + 401, + r, + PROVIDER_ERROR_TYPES.UNAUTHORIZED, + "mistral", + false, + BARE + ), + "expired" + ); + assert.equal( + resolveTerminalConnectionStatus( + 401, + {}, + PROVIDER_ERROR_TYPES.UNAUTHORIZED, + "mistral", + false, + BARE, + "c" + ), + "expired" + ); +}); + +async function createMistralConnection() { + const conn = await providersDb.createProviderConnection({ + provider: "mistral", + authType: "apikey", + apiKey: "mistral-test-key", + isActive: true, + testStatus: "active", + }); + return String(conn.id); +} + +async function expireCooldown(connId: string) { + // What selection-time auto-decay does once rateLimitedUntil has passed. + await providersDb.updateProviderConnection(connId, { + rateLimitedUntil: null, + testStatus: "active", + }); +} + +test("markAccountUnavailable, flag off: one bare Mistral 401 parks the connection as expired", async () => { + const connId = await createMistralConnection(); + + await auth.markAccountUnavailable(connId, 401, BARE, "mistral", "mistral-large-latest"); + + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.testStatus, "expired"); +}); + +test("markAccountUnavailable, flag on: cooldown for 3 bare 401s, then expired", async () => { + setFlag("true"); + const connId = await createMistralConnection(); + + for (let strike = 1; strike <= MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES; strike++) { + await auth.markAccountUnavailable(connId, 401, BARE, "mistral", "mistral-large-latest"); + const cooling = await providersDb.getProviderConnectionById(connId); + assert.equal(cooling.testStatus, "unavailable", `strike ${strike} cools down`); + assert.ok( + new Date(String(cooling.rateLimitedUntil)).getTime() > Date.now(), + `strike ${strike} sets a future rateLimitedUntil` + ); + await expireCooldown(connId); + } + + await auth.markAccountUnavailable(connId, 401, BARE, "mistral", "mistral-large-latest"); + const parked = await providersDb.getProviderConnectionById(connId); + assert.equal(parked.testStatus, "expired", "a persistent bare 401 still converges"); +}); + +test("markAccountUnavailable, flag on: an explicit auth signal parks on the first 401", async () => { + setFlag("true"); + const connId = await createMistralConnection(); + + await auth.markAccountUnavailable( + connId, + 401, + "Invalid API key", + "mistral", + "mistral-large-latest" + ); + + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.testStatus, "expired"); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index e68a020654..7136447948 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, 67); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 68); }); }); From c44f5da3884225130f4e090bada32ff8df8be31c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 21:35:03 -0300 Subject: [PATCH 34/36] fix(i18n): drop the duplicated flag description key and the duplicated ERROR_TYPE_CONTRACT import left by the batch merges (#13816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged with admin on local + CI evidence: `tests/unit/i18n-catalogs-no-duplicate-keys.test.ts` red on the tip (59 catalogs) → `pass 3 / fail 0` here; **API Route Typecheck passes on this PR** (it fails on every PR based on the current tip because of the duplicated `ERROR_TYPE_CONTRACT` import this removes); CodeQL, semgrep, Vitest fast-path, Docs gates, Change Classification pass. The remaining red checks (Fast Quality Gates, Merge integrity, Unit Tests fast-path 1/2/4) are the same inherited tip reds every PR on release/v3.8.51 shows right now — #13747 sweeps them. Both removed lines were byte-identical duplicates; nothing parsed or typed changes. --- ...02-i18n-duplicated-flag-description-key.md | 1 + src/i18n/messages/ar.json | 1 - src/i18n/messages/az.json | 1 - src/i18n/messages/bg.json | 1 - src/i18n/messages/bn.json | 1 - src/i18n/messages/cs.json | 1 - src/i18n/messages/da.json | 1 - src/i18n/messages/de.json | 1 - src/i18n/messages/el.json | 1 - src/i18n/messages/en.json | 1 - src/i18n/messages/es.json | 1 - src/i18n/messages/et.json | 1 - src/i18n/messages/fa.json | 1 - src/i18n/messages/fi.json | 1 - src/i18n/messages/fr.json | 1 - src/i18n/messages/ga.json | 1 - src/i18n/messages/gu.json | 1 - src/i18n/messages/he.json | 1 - src/i18n/messages/hi.json | 1 - src/i18n/messages/hr.json | 1 - src/i18n/messages/hu.json | 1 - src/i18n/messages/id.json | 1 - src/i18n/messages/it.json | 1 - src/i18n/messages/ja.json | 1 - src/i18n/messages/km.json | 1 - src/i18n/messages/kn.json | 1 - src/i18n/messages/ko.json | 1 - src/i18n/messages/lt.json | 1 - src/i18n/messages/lv.json | 1 - src/i18n/messages/ml.json | 1 - src/i18n/messages/mr.json | 1 - src/i18n/messages/ms.json | 1 - src/i18n/messages/mt.json | 1 - src/i18n/messages/my.json | 1 - src/i18n/messages/ne.json | 1 - src/i18n/messages/nl.json | 1 - src/i18n/messages/no.json | 1 - src/i18n/messages/or.json | 1 - src/i18n/messages/pa.json | 1 - src/i18n/messages/phi.json | 1 - src/i18n/messages/pl.json | 1 - src/i18n/messages/pt-BR.json | 1 - src/i18n/messages/pt.json | 1 - src/i18n/messages/ro.json | 1 - src/i18n/messages/ru.json | 1 - src/i18n/messages/si.json | 1 - src/i18n/messages/sk.json | 1 - src/i18n/messages/sl.json | 1 - src/i18n/messages/sr.json | 1 - src/i18n/messages/sv.json | 1 - src/i18n/messages/sw.json | 1 - src/i18n/messages/ta.json | 1 - src/i18n/messages/te.json | 1 - src/i18n/messages/th.json | 1 - src/i18n/messages/tr.json | 1 - src/i18n/messages/uk-UA.json | 1 - src/i18n/messages/ur.json | 1 - src/i18n/messages/vi.json | 1 - src/i18n/messages/zh-CN.json | 1 - src/i18n/messages/zh-TW.json | 1 - src/lib/db/callLogStats.ts | 1 - .../i18n-catalogs-no-duplicate-keys.test.ts | 123 ++++++++++++++++++ 62 files changed, 124 insertions(+), 60 deletions(-) create mode 100644 changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md create mode 100644 tests/unit/i18n-catalogs-no-duplicate-keys.test.ts diff --git a/changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md b/changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md new file mode 100644 index 0000000000..a82492e493 --- /dev/null +++ b/changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md @@ -0,0 +1 @@ +- **fix(i18n):** drop the second copy of `featureFlagProxySkipRecentlyFailedDescription` that the 2026-09-15 batch merges left in 59 dashboard catalogs (a scripted keep-both conflict resolution concatenated the key both PRs carried; `JSON.parse` silently kept the last copy) and the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` (TS2300); adds `tests/unit/i18n-catalogs-no-duplicate-keys.test.ts`, a raw-text guard that fails on any key declared twice in one object of `src/i18n/messages/*.json` or `bin/cli/locales/*.json` ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602), [#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index acc0eebbea..41df367baa 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 663d028ab8..81972d8d4d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 4049e2b21d..ba02624d17 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 9437ef38ea..6ddf474014 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index af621ae64f..9abb23fe98 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index f4e9a00f2b..ffabe002ee 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 011e8a8184..65b8e9deae 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index ab028b0ca6..9a2e2b43c0 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c1b6a74794..0a685beed5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3ce08c427c..18916f167f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index 1e38d61294..b72c7a30ee 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 78f22c2633..68f6e65028 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 5c0776995c..0c70a65d10 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 6190cc79ff..cceef76e5b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index b2594f676a..a3dcf8d2e5 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 85eb72dfc8..9328e707f7 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index a275126e2e..bcd41f8dc9 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index c1c1103479..5f0e06fdf5 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index 8e5fd6c688..e2672a03cd 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index eb72dc44ed..4a4478aa56 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5ec7c719e0..0fc0cc2302 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index e1cdadec1b..21c08e71ad 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fb2c8658bd..9c888b1654 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index df7fa2ac3a..34960ce503 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index e089761223..5e87887aad 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d80802fa59..9ba9dc3094 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index 4095245803..c7c6f53711 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 2ddff1c191..cc37c74fa6 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index feb1bb1576..f23874a8a0 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index daf19a6cae..71358f0175 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index d62c0f7357..95c1e7cb66 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index ce6561f74b..b7539064a5 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index cb89cf7fc2..25c91e45a1 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 3b67620b21..0b6318a9eb 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index e89c107ef0..05c5611a3b 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 1bd5cf3bfe..c0bd2006ed 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index ad34930fbd..5ab1756cc2 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 5f17b437a6..f3443a6100 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 3231c158d0..6e219167e1 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index e3132e6375..8990f83e12 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index bc85934a8c..8f680263e3 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -995,7 +995,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", - "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", "featureFlagProxyPoolEgressObservationDescription": "Mostra, abaixo de um pool de proxy no painel, quantos IPs de saída observados atenderam seus membros nas últimas 24 h, quantas conexões os usaram e o máximo visto atrás de um mesmo IP. Somente leitura, calculado a partir do log de proxy, nunca usado para roteamento. Desligado por padrão: o editor de pool não muda e a rota de observação responde null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "Na varredura de saúde de proxy, permite que uma sonda recusada pelo destino (401/403/429: o proxy retransmitiu, o destino recusou este IP de saída) zere a sequência de falhas consecutivas do proxy, como uma sonda atendida. Desligado por padrão: a recusa continua neutra e mantém a sequência. Um 5xx continua inconclusivo em qualquer caso, e uma recusa nunca remove, desativa ou reativa um proxy.", "sidebar": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index cdea0ba4b8..d428b35d82 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -995,7 +995,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 906e6cb0be..72505ae08d 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 956b75d6c2..4a6947620a 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index fb970ee279..b650ff5fef 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 06cc1d9345..49c8f233d6 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 22d34add46..dc2ebee38f 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 1a2d81ddb9..6aa55b927f 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index d509e3dfbc..24f39a19fc 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 3c0018a144..629174be52 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 99a3a6cdcd..d9c7b74fe1 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index d8e4783f7c..d0ace81b9f 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index b7eef7ba23..9ad2fe5e89 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 921a100dba..a2c1a7125b 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index ed07ebe6cc..601254f7a2 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 21764b19b6..3f782246af 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index a1c085f7cf..426c36546d 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -995,7 +995,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", - "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", "featureFlagProxyPoolEgressObservationDescription": "Hiển thị, bên dưới một nhóm proxy trong bảng điều khiển, số IP đầu ra quan sát được đã phục vụ các thành viên của nhóm trong 24 giờ qua, số kết nối đã dùng chúng và số lớn nhất thấy sau cùng một IP. Chỉ đọc, tính từ nhật ký proxy, không bao giờ dùng để định tuyến. Tắt theo mặc định: trình chỉnh sửa nhóm không đổi và tuyến quan sát trả về null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "Trong lượt kiểm tra sức khỏe proxy, cho phép một lần thăm dò bị đích từ chối (401/403/429: proxy đã chuyển tiếp, đích từ chối IP đầu ra này) đặt lại chuỗi lỗi liên tiếp của proxy, giống như một lần thăm dò được phục vụ. Tắt theo mặc định: lần từ chối vẫn trung lập và giữ nguyên chuỗi. Lỗi 5xx vẫn không kết luận trong mọi trường hợp, và lần từ chối không bao giờ xóa, vô hiệu hóa hay kích hoạt lại proxy.", "sidebar": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 3947dd5aae..7f363529a8 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index eec21fe864..5da99c77fe 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -994,7 +994,6 @@ "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.", "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", - "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts index 35f7263919..24debd628e 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -1,6 +1,5 @@ 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, diff --git a/tests/unit/i18n-catalogs-no-duplicate-keys.test.ts b/tests/unit/i18n-catalogs-no-duplicate-keys.test.ts new file mode 100644 index 0000000000..2b963454d3 --- /dev/null +++ b/tests/unit/i18n-catalogs-no-duplicate-keys.test.ts @@ -0,0 +1,123 @@ +/** + * Regression guard: no i18n catalog (dashboard `src/i18n/messages/*.json`, CLI + * `bin/cli/locales/*.json`) may declare the same key twice inside one object. + * + * `JSON.parse` silently keeps the LAST copy, so a duplicated key never fails a + * parse, a coverage gate, prettier or a focused test — it only shows up as a + * translation that "does not update" or as a diff that grows on every merge. + * The 2026-09-15 batch merges left `featureFlagProxySkipRecentlyFailedDescription` + * twice in 59 locales (a scripted keep-both conflict resolution concatenated the + * key both PRs carried). This test reads the raw text, not the parsed object. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const CATALOG_DIRS = ["src/i18n/messages", "bin/cli/locales"]; + +interface ObjectFrame { + type: "obj"; + keys: Set; + expectKey: boolean; + lastKey: string; + path: string[]; +} +interface ArrayFrame { + type: "arr"; + path: string[]; +} +type Frame = ObjectFrame | ArrayFrame; + +/** Returns the dotted paths of every key declared more than once in its object. */ +export function findDuplicateKeys(text: string): string[] { + const dups: string[] = []; + const stack: Frame[] = []; + let i = 0; + const n = text.length; + + const readString = (): string => { + // text[i] is the opening quote; escapes are kept raw — identity only matters here + let j = i + 1; + let out = ""; + while (j < n) { + const c = text[j]; + if (c === "\\") { + out += c + text[j + 1]; + j += 2; + continue; + } + if (c === '"') break; + out += c; + j++; + } + i = j + 1; + return out; + }; + + const childPath = (): string[] => { + const parent = stack[stack.length - 1]; + if (!parent) return []; + return parent.type === "obj" ? [...parent.path, parent.lastKey] : [...parent.path, "[]"]; + }; + + while (i < n) { + const c = text[i]; + if (c === '"') { + const s = readString(); + const top = stack[stack.length - 1]; + if (top && top.type === "obj" && top.expectKey) { + if (top.keys.has(s)) dups.push([...top.path, s].join(".")); + top.keys.add(s); + top.expectKey = false; + top.lastKey = s; + } + continue; + } + if (c === "{") { + stack.push({ type: "obj", keys: new Set(), expectKey: true, lastKey: "", path: childPath() }); + } else if (c === "[") { + stack.push({ type: "arr", path: childPath() }); + } else if (c === "}" || c === "]") { + stack.pop(); + } else if (c === ",") { + const top = stack[stack.length - 1]; + if (top && top.type === "obj") top.expectKey = true; + } + i++; // colons, whitespace and primitive values carry no key information + } + return dups; +} + +test("findDuplicateKeys reports repeated keys per object and nothing else", () => { + assert.deepEqual(findDuplicateKeys('{"a": 1, "a": 2}'), ["a"]); + assert.deepEqual( + findDuplicateKeys('{"a": {"x": 1}, "b": {"x": 1}, "c": ["x", "x"], "d": "a"}'), + [] + ); + assert.deepEqual(findDuplicateKeys('{"a": {"x": 1, "x": 2}}'), ["a.x"]); + assert.deepEqual(findDuplicateKeys('{"k": "v", "k": "v"}'), ["k"]); + assert.deepEqual(findDuplicateKeys('{"esc\\"aped": 1, "esc\\"aped": 2}'), ['esc\\"aped']); + assert.deepEqual(findDuplicateKeys('{"n": [{"a": 1}, {"a": 1}]}'), []); +}); + +for (const dir of CATALOG_DIRS) { + test(`${dir}: no catalog declares the same key twice in one object`, () => { + const files = readdirSync(path.join(ROOT, dir)) + .filter((f) => f.endsWith(".json")) + .sort(); + assert.ok(files.length > 0, `no catalogs found under ${dir}`); + const offenders: string[] = []; + for (const f of files) { + const dups = [...new Set(findDuplicateKeys(readFileSync(path.join(ROOT, dir, f), "utf8")))]; + if (dups.length > 0) offenders.push(`${f}: ${dups.join(", ")}`); + } + assert.deepEqual( + offenders, + [], + `duplicated keys — JSON.parse keeps only the last copy:\n${offenders.join("\n")}` + ); + }); +} From 997cd4d509ab55df5d60f65ea11ce9f3049d0016 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:01:56 +0200 Subject: [PATCH 35/36] fix(sse): stop retry wave on rate-limited 429 and drain 429 once (#13657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode executor classifies rate-limited 429 bodies (`classify429`, with real tests) and, when a whole account wave is exhausted, returns the last real upstream 429 — status, body, `Retry-After` and quota headers intact — so the provider error rules (monthly-quota cooldown) keep working. Maintainer rework before merge (kept the idea, no default behavior change): - The original stopped the cross-account wave at the first classified 429 and replaced the response with a synthetic one that dropped the body and headers; stopping early is now opt-in behind `OPENCODE_RATE_LIMITED_429_EARLY_STOP` (default off), the rate-limited account is still cooled down, the body is read as a bounded 8 KiB prefix from a clone and the original is never consumed, and the unused `status` input is gone. Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests. Thanks @maxmad64bis! --- ...657-rate-limited-429-single-retry-drain.md | 1 + docs/reference/FEATURE_FLAGS.md | 7 +- open-sse/executors/opencode.ts | 15 + open-sse/executors/opencodeRateLimited.ts | 92 ++++++ .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 17 ++ tests/unit/feature-flags-settings.test.ts | 13 +- .../opencode-rate-limited-classify.test.ts | 277 ++++++++++++++++++ .../unit/server-owned-tool-loop-flag.test.ts | 2 +- 9 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md create mode 100644 open-sse/executors/opencodeRateLimited.ts create mode 100644 tests/unit/opencode-rate-limited-classify.test.ts diff --git a/changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md b/changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md new file mode 100644 index 0000000000..93f2bd0166 --- /dev/null +++ b/changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md @@ -0,0 +1 @@ +- **fix(sse):** opt-in `OPENCODE_RATE_LIMITED_429_EARLY_STOP` flag (default off): an opencode 429 classified as a real rate limit (parseable `Retry-After`, or a body naming a rate/usage limit) stops the cross-account wave and returns that upstream 429 unchanged — body, `Retry-After` and quota headers intact, so the opencode quota error rules still apply; unclassified 429s keep rotating, and with the flag off every 429 rotates as before (#9611) ([#13657](https://github.com/diegosouzapw/OmniRoute/pull/13657)) — thanks @maxmad64bis diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 8f4f9c72a1..7c203eae7d 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 -68 flags across 6 categories. **Default** is the definition default — the value +69 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) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (14) +### Network (15) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -78,6 +78,7 @@ used when neither a DB override nor an environment variable is present. | `OPENCODE_RESPONSES_STALL_ROTATION` | boolean | `false` | | For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: `RESPONSES_FIRST_BYTE_TIMEOUT_MS`, default `15000`). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout. | | `OPENCODE_USER_BLOCKED_ROTATION` | boolean | `false` | | OpenCode executor: on a 403/451 carrying a `user_blocked` refusal (not geo, not a Cloudflare fingerprint rejection), cool the refused account down and rotate to the next account at most once per request; a second refusal is returned as-is, without a success mark. Off by default: routing around an upstream user block can look like evasion and spread the flag across the fleet. | | `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` | boolean | `false` | | OpenCode rotation: after two consecutive transient upstream failures (5xx or an empty 400), pause before the next account — 1.5s doubling per further failure, capped at 6s per pause and 10s per request, skipped on client disconnect; the failed body is released before waiting. Off by default: failover stays immediate. | +| `OPENCODE_RATE_LIMITED_429_EARLY_STOP` | boolean | `false` | | OpenCode rotation: stop the account wave at the first 429 classified as a real rate limit (parseable `Retry-After`, or a body naming a rate/usage limit) and return that upstream 429 unchanged. Unclassified 429s keep rotating. Off by default: the free tier is limited per egress IP (#9611), so every 429 rotates and an exhausted wave returns the last upstream 429. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -208,7 +209,7 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 68 flags + // ... all 69 flags ], "summary": { "total": 56, diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 754f0f284e..9c18e7945a 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -53,7 +53,9 @@ import { isProxySkipRecentlyFailedEnabled, isOpencodeUserBlockedRotationEnabled, isOpencodeTransientFailoverBackoffEnabled, + isOpencodeRateLimited429EarlyStopEnabled, } from "@/shared/utils/featureFlags"; +import { classifyUpstream429 } from "./opencodeRateLimited.ts"; /** * The main OpenCode Zen host, shared by the `opencode` and `opencode-zen` @@ -791,6 +793,19 @@ export class OpencodeExecutor extends BaseExecutor { const setAsideMs = skipRecentlyFailed ? noteProxyRefusal(proxyEgressKey(account.proxy), "ip_quota_429") : null; + // Opt-in (#13657): a 429 that names a real rate limit stops the wave and + // the real upstream 429 is returned untouched (body, Retry-After, quota + // headers), so provider error rules still apply. Flag off → rotate. + if ( + isOpencodeRateLimited429EarlyStopEnabled() && + (await classifyUpstream429(result.response)) === "rate_limited" + ) { + log?.warn?.( + "OPENCODE", + `${cid}rate-limited 429 on account ${masked}, stopping the account wave` + ); + return result; + } log?.warn?.( "OPENCODE", `${cid}Rate limited (429) on account ${masked}` + diff --git a/open-sse/executors/opencodeRateLimited.ts b/open-sse/executors/opencodeRateLimited.ts new file mode 100644 index 0000000000..0bf8e7dec3 --- /dev/null +++ b/open-sse/executors/opencodeRateLimited.ts @@ -0,0 +1,92 @@ +/** + * opencodeRateLimited.ts — 429 classifier for the opencode executor loop (#13657). + * + * Leaf module: zero imports, no registry, no DB. Headers first: a parseable + * Retry-After alone marks a real rate limit; otherwise a bounded prefix of the + * body is matched against generic English rate-limit phrasings (derived from a + * captured 429 body). Anything else is a "burst" 429 that keeps the normal + * cross-account rotation. Only consulted when OPENCODE_RATE_LIMITED_429_EARLY_STOP + * is on; the classifier never rewrites the response it inspects. + */ + +const RATE_LIMITED_SIGNALS: ReadonlyArray = [ + /rate.?limited/i, + /usage.?limit/i, + /too many requests/i, +]; + +/** Bytes of a 429 body inspected by the classifier. */ +export const RATE_LIMIT_BODY_SNIFF_BYTES = 8192; + +/** Seconds until retry from a Retry-After value (delta-seconds or HTTP date), or null. */ +export function parseRetryAfterSeconds( + retryAfter: string | number | null | undefined, + now = Date.now() +): number | null { + if (typeof retryAfter === "number") { + return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.ceil(retryAfter) : null; + } + if (typeof retryAfter !== "string") return null; + const text = retryAfter.trim(); + if (text === "") return null; + if (/^\d+$/.test(text)) return Math.max(Number(text), 1); + const ms = Date.parse(text); + if (Number.isFinite(ms)) return Math.max(Math.ceil((ms - now) / 1000), 1); + return null; +} + +export type RateLimit429Verdict = "rate_limited" | "burst"; + +export function classify429(input: { + retryAfter?: string | number | null; + bodyText?: string | null; +}): RateLimit429Verdict { + if (parseRetryAfterSeconds(input.retryAfter) !== null) return "rate_limited"; + const body = typeof input.bodyText === "string" ? input.bodyText : ""; + if (body !== "" && RATE_LIMITED_SIGNALS.some((re) => re.test(body))) return "rate_limited"; + return "burst"; +} + +/** + * Read at most `maxBytes` of a response body from a clone, then cancel the + * clone's reader. The original response keeps its full, unread body. Returns + * null when the body cannot be read. + */ +export async function readBodyPrefix( + response: Response, + maxBytes = RATE_LIMIT_BODY_SNIFF_BYTES +): Promise { + if (!response.body) return ""; + let reader: ReadableStreamDefaultReader | undefined; + try { + reader = response.clone().body?.getReader(); + } catch { + return null; + } + if (!reader) return ""; + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + try { + while (bytes < maxBytes) { + const { done, value } = await reader.read(); + if (done || !value) break; + const chunk = + value.byteLength > maxBytes - bytes ? value.subarray(0, maxBytes - bytes) : value; + bytes += chunk.byteLength; + text += decoder.decode(chunk, { stream: true }); + } + return text + decoder.decode(); + } catch { + return null; + } finally { + void reader.cancel().catch(() => undefined); + } +} + +/** Classify an upstream 429: Retry-After header first, bounded body prefix only if needed. */ +export async function classifyUpstream429(response: Response): Promise { + const retryAfter = response.headers.get("retry-after"); + if (parseRetryAfterSeconds(retryAfter) !== null) return "rate_limited"; + return classify429({ retryAfter, bodyText: await readBodyPrefix(response) }); +} diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 6b87df1e11..ee089d74a5 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -251,6 +251,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "OPENCODE_RATE_LIMITED_429_EARLY_STOP", + label: "OpenCode Rate-Limited 429 Early Stop", + description: + "For the OpenCode multi-account rotation, stop the account wave at the first 429 classified as a real rate limit (a parseable Retry-After header, or a body naming a rate/usage limit) and return that upstream 429 unchanged (status, body, Retry-After and quota headers), instead of trying every remaining account. Unclassified 429s keep rotating. Off by default: the free tier is limited per egress IP (#9611), so every 429 rotates to the next account, and an exhausted wave returns the last upstream 429.", + descriptionI18nKey: "featureFlagOpencodeRateLimited429EarlyStopDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index e479e72a61..4f003e9e8a 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -289,6 +289,23 @@ export function isMistralAmbiguous401SoftLockoutEnabled(): boolean { } } +/** + * OpenCode classified-429 early stop (#13657). Opt-in: when off, every 429 rotates to the + * next account exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeRateLimited429EarlyStopEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_RATE_LIMITED_429_EARLY_STOP"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_RATE_LIMITED_429_EARLY_STOP, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index e725b8b546..c521b3f793 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -40,7 +40,7 @@ const { // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. // #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. -const EXPECTED_FEATURE_FLAG_COUNT = 68; +const EXPECTED_FEATURE_FLAG_COUNT = 69; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -242,6 +242,17 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines OPENCODE_RATE_LIMITED_429_EARLY_STOP as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "OPENCODE_RATE_LIMITED_429_EARLY_STOP" + ); + assert.ok(def, "OPENCODE_RATE_LIMITED_429_EARLY_STOP should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" diff --git a/tests/unit/opencode-rate-limited-classify.test.ts b/tests/unit/opencode-rate-limited-classify.test.ts new file mode 100644 index 0000000000..43cbcc3b59 --- /dev/null +++ b/tests/unit/opencode-rate-limited-classify.test.ts @@ -0,0 +1,277 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { + classify429, + classifyUpstream429, + parseRetryAfterSeconds, + readBodyPrefix, + RATE_LIMIT_BODY_SNIFF_BYTES, +} from "../../open-sse/executors/opencodeRateLimited.ts"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { getProviderErrorRuleMatch } from "../../open-sse/config/providerErrorRules.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// #13657 rework: the 429 classifier is kept; stopping the cross-account wave at a +// classified 429 is opt-in (OPENCODE_RATE_LIMITED_429_EARLY_STOP, default off — +// the free tier is per egress IP, #9611). Whatever ends the wave, the client gets +// the REAL last upstream 429 (status, body, Retry-After, quota headers), never a +// synthetic drain, so the opencode provider error rules keep matching it. +const FLAG = "OPENCODE_RATE_LIMITED_429_EARLY_STOP"; +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FPS = ["x", "y", "z"].map((c) => c.repeat(32)); +const MONTHLY_BODY = JSON.stringify({ + error: { + message: + "[429] Monthly usage limit reached. Resets in 13 days. To continue using this model now, enable usage from your available balance.", + }, +}); + +describe("classify429 / parseRetryAfterSeconds", () => { + it("a parseable Retry-After alone classifies rate_limited", () => { + assert.strictEqual(classify429({ retryAfter: "30" }), "rate_limited"); + assert.strictEqual(classify429({ retryAfter: 45 }), "rate_limited"); + const future = new Date(Date.now() + 120_000).toUTCString(); + assert.strictEqual(classify429({ retryAfter: future }), "rate_limited"); + }); + + it("a body naming a rate or usage limit classifies rate_limited", () => { + assert.strictEqual(classify429({ bodyText: "Rate limited, slow down" }), "rate_limited"); + assert.strictEqual(classify429({ bodyText: "Too many requests" }), "rate_limited"); + assert.strictEqual(classify429({ bodyText: MONTHLY_BODY }), "rate_limited"); + }); + + it("anything else is a burst", () => { + assert.strictEqual(classify429({}), "burst"); + assert.strictEqual(classify429({ bodyText: '{"error":"boom"}' }), "burst"); + assert.strictEqual(classify429({ retryAfter: "not-a-date" }), "burst"); + assert.strictEqual(classify429({ retryAfter: "" }), "burst"); + }); + + it("parses delta-seconds and HTTP dates against an injected clock", () => { + const now = Date.parse("2026-09-15T00:00:00Z"); + assert.strictEqual(parseRetryAfterSeconds("30", now), 30); + assert.strictEqual(parseRetryAfterSeconds("Tue, 15 Sep 2026 00:02:00 GMT", now), 120); + assert.strictEqual(parseRetryAfterSeconds("not-a-date", now), null); + assert.strictEqual(parseRetryAfterSeconds(-5, now), null); + }); +}); + +describe("readBodyPrefix / classifyUpstream429", () => { + it("reads only a bounded prefix and leaves the original body intact", async () => { + const body = "a".repeat(RATE_LIMIT_BODY_SNIFF_BYTES) + " rate limited"; + const response = new Response(body, { status: 429 }); + const prefix = await readBodyPrefix(response); + assert.strictEqual( + prefix?.length, + RATE_LIMIT_BODY_SNIFF_BYTES, + "signal past the cap is unseen" + ); + assert.strictEqual(await classifyUpstream429(response), "burst"); + assert.strictEqual(response.bodyUsed, false); + assert.strictEqual(await response.text(), body, "the caller still gets the full body"); + }); + + it("checks the header before touching the body", async () => { + const response = new Response("Too many requests", { + status: 429, + headers: { "Retry-After": "7" }, + }); + assert.strictEqual(await classifyUpstream429(response), "rate_limited"); + assert.strictEqual(await response.text(), "Too many requests"); + }); +}); + +describe("OpencodeExecutor 429 wave", () => { + const servers: net.Server[] = []; + const ports: number[] = []; + let originalFetch: typeof globalThis.fetch; + let priorFlag: string | undefined; + let observed: string[]; + + before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push( + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)) + ) + ); + } + }); + + after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); + }); + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorFlag = process.env[FLAG]; + observed = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + type Step = { status: number; body?: string; headers?: Record }; + + function installFetch(plan: Step[]) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + return new Response(step.body ?? JSON.stringify({ ok: step.status === 200, call }), { + status: step.status, + headers: { "Content-Type": "application/json", ...(step.headers ?? {}) }, + }); + }) as typeof globalThis.fetch; + } + + function credentials(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; + } + + async function run(exec: OpencodeExecutor, count: number) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentials(count), + log, + })) as { response: Response }; + return result.response; + } + + function cooled(exec: OpencodeExecutor): number { + const accounts = (exec as unknown as { accounts: Array<{ cooldownUntil: number }> }).accounts; + return accounts.filter((a) => a.cooldownUntil > Date.now()).length; + } + + const RATE_LIMITED: Step = { + status: 429, + body: MONTHLY_BODY, + headers: { "Retry-After": "30", "x-ratelimit-remaining-requests": "0" }, + }; + + it("flag off: a classified 429 still rotates to the next account (#9611)", async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([RATE_LIMITED, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 2); + await response.body?.cancel(); + }); + + it("flag off: an exhausted wave returns the last real upstream 429 untouched", async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 429, body: '{"error":"first"}' }, + { status: 429, body: '{"error":"second"}' }, + RATE_LIMITED, + ]); + + const response = await run(exec, 3); + + assert.strictEqual(observed.length, 3); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("retry-after"), "30"); + assert.strictEqual(response.headers.get("x-ratelimit-remaining-requests"), "0"); + assert.strictEqual(response.headers.get("x-opencode-retry-state"), null, "nothing synthetic"); + assert.strictEqual(await response.text(), MONTHLY_BODY); + }); + + describe("flag on", () => { + beforeEach(() => { + process.env[FLAG] = "true"; + }); + + it("stops at the first classified 429 and returns it untouched", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([RATE_LIMITED, { status: 200 }]); + + const response = await run(exec, 3); + + assert.strictEqual(observed.length, 1, "no further account is tried"); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("retry-after"), "30"); + assert.strictEqual(response.headers.get("x-ratelimit-remaining-requests"), "0"); + assert.strictEqual(cooled(exec), 1, "the rate-limited account is cooled down"); + const text = await response.text(); + assert.strictEqual(text, MONTHLY_BODY, "upstream body preserved"); + const rule = getProviderErrorRuleMatch( + "opencode-zen", + 429, + Object.fromEntries(response.headers.entries()), + JSON.parse(text) + ); + assert.strictEqual(rule?.reason, "quota_exhausted", "provider error rules still match"); + assert.ok((rule?.cooldownMs ?? 0) > 24 * 60 * 60 * 1000, "the 13-day reset still applies"); + }); + + it("a body-only signal stops without inventing a Retry-After", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 429, body: '{"error":"Rate limited"}' }, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(observed.length, 1); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("retry-after"), null); + assert.strictEqual(await response.text(), '{"error":"Rate limited"}'); + }); + + it("an unclassified (burst) 429 keeps rotating", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 429, body: '{"error":"boom"}' }, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 2); + await response.body?.cancel(); + }); + + it("an all-burst wave still returns the last real upstream 429", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 429, body: '{"error":"a"}' }, + { status: 429, body: '{"error":"b"}', headers: { "x-upstream": "last" } }, + ]); + + const response = await run(exec, 2); + + assert.strictEqual(observed.length, 2); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("x-upstream"), "last"); + assert.strictEqual(await response.text(), '{"error":"b"}'); + }); + }); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 7136447948..efd7817287 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, 68); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 69); }); }); From 8f55d85d221e8df0b788eab0e598935a1514536a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 15 Sep 2026 22:48:19 -0300 Subject: [PATCH 36/36] fix(ci): clear the release/v3.8.51 base-reds left by the 09-15 batch (stryker, CLI i18n, paid-target fixture, call-log traceId, Jina prefix, callLogStats import, gitleaks) (#13747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): clear the release/v3.8.51 base-reds left by the 09-15 batch — stryker coverage, CLI ready_timeout key, paid-target fixture, call-log traceId, Jina custom prefix Every PR into release/v3.8.51 pushed after #13635/#13678 still failed Fast Quality Gates and all four Unit fast-path shards on the same 16 tests. Each one reproduces on the pure tip; none is a product defect: - mutation-test-coverage: noauth-model-lockout and local-token-budget-429-skips-cooldown (#13606) were missing from stryker.conf.json tap.testFiles. - cli-i18n-catalog: --ready-timeout calls t("serve.ready_timeout") with no catalog entry; added to en, zh-CN and zh-TW (the parity-checked locales). - paid-model-target(-routes)-6540: #13407 removed Together's one-time credit from the free catalog, so "together/..." classifies as unknown and the save-time guard correctly lets it through. Fixture is now gemini/gemini-3.1-pro-preview, plus a precondition test on the fixtures. - attempt-logging-early-keepalive-merge / video-bridge-log-redaction: #13546 keys the call-log row on traceId; baseCtx now defaults traceId to pendingRequestId (same pattern as chatcore-attempt-logging). The keepalive test also moves to the 30s wall-clock poll deadline video-bridge uses. - models-catalog-route: custom Jina rows keep the jina-ai/ prefix; #13403 changed the custom assertion to jina/ (only synced rows use the alias). Refs #12732 * fix(ci): clear the four reds the first r4 CI run surfaced — callLogStats duplicate import, Uzbek gitleaks false positive, redaction probe traceId, file-size - src/lib/db/callLogStats.ts: the #13641 merge left ERROR_TYPE_CONTRACT imported twice (TS2300), failing API Route Typecheck and check:dashboard-typecheck on every PR. - .gitleaks.toml: the Uzbek catalog from #13727 translates outputTokenDesc as "Yakunlash/javob tokenlari"; generic-api-key reads it as a token value. - dashboard-request-failed-redaction-probe: reads the persisted row by traceId (#13546); with pendingRequestId it asserts null. - models-catalog-route: drop the explanatory comment, which pushed the frozen file over its size cap; the rationale lives in the changelog fragment. Refs #12732 * fix(ci): re-freeze the two test files #13748/#13749 grew past their file-size caps PR-mode check:file-size relaxes source files against the base but not testFrozen, so image-generation-handler.test.ts (2133->2235, #13748) and batch_api.test.ts (1345->1348, #13749) failed Fast Quality Gates on every PR, this one included. Caps set to the merged LOC, with the justification entry. Refs #12732 * fix(ci): register free-badge-provider-gate (#13645) in stryker tap.testFiles #13645 landed a covering test for src/sse/services/auth.ts without the stryker entry, so the strict mutation-test-coverage gate went red again. Refs #12732 * fix(ci): clear two more base-reds the #13440/#13439 merges added - stryker.conf.json: register daily-reset-tz-threading (#13440), which covers accountFallback.ts and rrState.ts. - .gitleaks.toml: allowlist the PROTECTED_PRIORITY_INFRA_502_ENABLED flag id (#13439); generic-api-key reads its key: as a token (secrets ratchet 0 -> 1). Refs #12732 * docs(changelog): tidy the stryker base-red fragment wording Refs #12732 --- .gitleaks.toml | 7 +++++++ bin/cli/locales/en.json | 1 + bin/cli/locales/zh-CN.json | 1 + bin/cli/locales/zh-TW.json | 1 + .../12732-attempt-log-tests-trace-id.md | 1 + .../12732-call-log-stats-duplicate-import.md | 1 + .../12732-cli-serve-ready-timeout-i18n.md | 1 + .../12732-file-size-test-growth-13748-13749.md | 1 + .../12732-gitleaks-uzbek-token-desc.md | 1 + .../12732-jina-custom-rows-provider-prefix.md | 1 + .../12732-paid-target-fixture-gemini-pro.md | 1 + ...12732-stryker-noauth-token-budget-coverage.md | 1 + config/quality/file-size-baseline.json | 5 +++-- stryker.conf.json | 4 ++++ .../dashboard-request-failed-redaction-probe.ts | 3 ++- ...attempt-logging-early-keepalive-merge.test.ts | 16 +++++++++++++--- tests/unit/models-catalog-route.test.ts | 4 ++-- tests/unit/paid-model-target-6540.test.ts | 2 +- tests/unit/paid-model-target-routes-6540.test.ts | 13 +++++++++++-- tests/unit/video-bridge-log-redaction.test.ts | 4 ++++ 20 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 changelog.d/maintenance/12732-attempt-log-tests-trace-id.md create mode 100644 changelog.d/maintenance/12732-call-log-stats-duplicate-import.md create mode 100644 changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md create mode 100644 changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md create mode 100644 changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md create mode 100644 changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md create mode 100644 changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md create mode 100644 changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md diff --git a/.gitleaks.toml b/.gitleaks.toml index 86e5f49649..3768394fbe 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -97,4 +97,11 @@ # credential; the generic-api-key rule flags the long hyphenated string. '''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''', '''SunbreakWebUI1''', + # Uzbek dashboard catalog (#13727, src/i18n/messages/uz.json `outputTokenDesc`): + # "Yakunlash/javob tokenlari" = "completion/response tokens". The rule reads the + # `...TokenDesc` key as a token assignment and the translated words as its value. + '''Yakunlash/javob''', + # Feature-flag id from #13439 (src/shared/constants/featureFlagDefinitions.ts): + # `key: "PROTECTED_PRIORITY_INFRA_502_ENABLED"` is a flag name, not a credential. + '''PROTECTED_PRIORITY_INFRA_502_ENABLED''', ] diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 3ed2f2dbcf..4b37832ba3 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -256,6 +256,7 @@ "max_restarts": "Max crash restarts within 30s before giving up (default: 2)", "tray": "Start in the system tray (desktop only, opt-in)", "no_tray": "Disable system tray icon", + "ready_timeout": "Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)", "tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)", "tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)" }, diff --git a/bin/cli/locales/zh-CN.json b/bin/cli/locales/zh-CN.json index 31be9d4c16..d3f1ab718b 100644 --- a/bin/cli/locales/zh-CN.json +++ b/bin/cli/locales/zh-CN.json @@ -254,6 +254,7 @@ "max_restarts": "30 秒内的最大崩溃重启次数(默认:2)", "tray": "显示系统托盘图标(仅桌面,选择加入)", "no_tray": "禁用系统托盘图标", + "ready_timeout": "就绪探测超时(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS,默认 60000)", "tls_cert": "用于提供 HTTPS 服务的 TLS 证书(PEM)路径(也可用 OMNIROUTE_TLS_CERT)", "tls_key": "用于提供 HTTPS 服务的 TLS 私钥(PEM)路径(也可用 OMNIROUTE_TLS_KEY)" }, diff --git a/bin/cli/locales/zh-TW.json b/bin/cli/locales/zh-TW.json index fa7ca866b8..f4c9c39e10 100644 --- a/bin/cli/locales/zh-TW.json +++ b/bin/cli/locales/zh-TW.json @@ -254,6 +254,7 @@ "max_restarts": "30 秒內的最大崩潰重啟次數(預設:2)", "tray": "顯示系統托盤圖示(僅桌面,選擇加入)", "no_tray": "停用系統托盤圖示", + "ready_timeout": "就緒探測逾時(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS,預設 60000)", "tls_cert": "用於提供 HTTPS 服務的 TLS 憑證(PEM)路徑(也可用 OMNIROUTE_TLS_CERT)", "tls_key": "用於提供 HTTPS 服務的 TLS 私鑰(PEM)路徑(也可用 OMNIROUTE_TLS_KEY)" }, diff --git a/changelog.d/maintenance/12732-attempt-log-tests-trace-id.md b/changelog.d/maintenance/12732-attempt-log-tests-trace-id.md new file mode 100644 index 0000000000..cb2365161a --- /dev/null +++ b/changelog.d/maintenance/12732-attempt-log-tests-trace-id.md @@ -0,0 +1 @@ +- **test(call-logs):** the early-keepalive merge and video-bridge redaction tests pass a `traceId` (defaulting to `pendingRequestId`) now that #13546 keys each attempt's call-log row on it, the dashboard `request.failed` redaction probe reads the persisted row by `traceId`, and the keepalive test polls against a 30s wall-clock deadline like the video-bridge test instead of a 2.4s try count ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-call-log-stats-duplicate-import.md b/changelog.d/maintenance/12732-call-log-stats-duplicate-import.md new file mode 100644 index 0000000000..7c6af99ee4 --- /dev/null +++ b/changelog.d/maintenance/12732-call-log-stats-duplicate-import.md @@ -0,0 +1 @@ +- **fix(db):** drop the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` left by the #13641 merge; the TS2300 duplicate-identifier error failed the API-route and dashboard typecheck gates on every PR ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md b/changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md new file mode 100644 index 0000000000..e54864c118 --- /dev/null +++ b/changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md @@ -0,0 +1 @@ +- **fix(cli):** add the `serve.ready_timeout` string to the `en`, `zh-CN` and `zh-TW` CLI catalogs; `--ready-timeout` shipped calling `t("serve.ready_timeout")` without a catalog entry, which the CLI i18n key-coverage and parity tests report ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md b/changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md new file mode 100644 index 0000000000..d37ac87858 --- /dev/null +++ b/changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md @@ -0,0 +1 @@ +- **fix(ci):** re-freeze `tests/unit/image-generation-handler.test.ts` (2133→2235, #13748) and `tests/unit/batch_api.test.ts` (1345→1348, #13749) at their merged size; PR-mode `check:file-size` does not relax `testFrozen` against the base, so that regression coverage turned the gate red on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md b/changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md new file mode 100644 index 0000000000..7a7a65615c --- /dev/null +++ b/changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md @@ -0,0 +1 @@ +- **fix(ci):** allowlist the Uzbek `outputTokenDesc` translation ("Yakunlash/javob tokenlari") and the `PROTECTED_PRIORITY_INFRA_502_ENABLED` feature-flag id (#13439) in `.gitleaks.toml`; the `generic-api-key` rule reads the `...TokenDesc` key and the flag `key:` as token assignments, which the secrets ratchet reported as new findings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md b/changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md new file mode 100644 index 0000000000..543617a21b --- /dev/null +++ b/changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md @@ -0,0 +1 @@ +- **test(models):** the custom Jina specialty-model catalog test expects the `jina-ai/` prefix again: custom rows keep the connection provider id, only synced rows resolve through the `jina` alias, and #13403 had switched the custom assertion to `jina/` ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md b/changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md new file mode 100644 index 0000000000..9520ada679 --- /dev/null +++ b/changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md @@ -0,0 +1 @@ +- **test(settings):** the #6540 paid-target tests now use `gemini/gemini-3.1-pro-preview` as the paid fixture and assert the fixtures still classify as paid/free/unknown; the old Together target became "unknown" once #13407 removed Together's one-time signup credit from the free catalog, so the three save-time blocking tests read a correct 200 as a missing guard ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md b/changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md new file mode 100644 index 0000000000..34cbaedb43 --- /dev/null +++ b/changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md @@ -0,0 +1 @@ +- **fix(ci):** register `noauth-model-lockout`, `local-token-budget-429-skips-cooldown`, `free-badge-provider-gate` (#13645) and `daily-reset-tz-threading` (#13440) in `stryker.conf.json` `tap.testFiles`; they cover `accountFallback.ts`/`auth.ts`/`comboPredicates.ts`/`rrState.ts`, so the strict `mutation-test-coverage` gate failed Fast Quality Gates on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3de72e8e61..79d974eab8 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -235,12 +235,13 @@ "_rebaseline_2026_07_22_8213_combo_config_cooldown_wait_tests": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: tests/unit/combo-config.test.ts 880->940 (+60, entirely this PR's diff — testFrozen add covering isComboCooldownWaitEligible (gating cooldown-wait to auto/quota-share strategies with the feature enabled) and resolveComboTargetTimeoutMsForCombo (raising the per-target timeout floor to cover the cooldown-wait budget + buffer for eligible strategies, fixing the 120s default cutting off a 130s wait early and returning a synthetic 524)). Covered by the new assertions themselves.", "_rebaseline_2026_07_23_8122_codex_image_edits": "#8122 (@xiaoyaner0201) own growth: tests/unit/image-generation-handler.test.ts 2019->2029 (+10) — new coverage for Codex reference image edits (POST /v1/images/edits) plus the sanitizeImageProviderError/redactSensitiveErrorText hardening it introduces. Test-only growth at the existing handler test file.", "_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff — new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.", + "_rebaseline_2026_09_15_13748_13749_merged_test_growth_basereds": "Base-red drain (#12732): two security fixes merged on 2026-09-15 each grew a frozen test file with their own regression coverage, and PR-mode check:file-size does not relax testFrozen against the base, so every PR into release/v3.8.51 went red on file-size. Recorded against the merged state: tests/unit/image-generation-handler.test.ts 2133->2235 (#13748 public-only guard on client-supplied image URLs); tests/unit/batch_api.test.ts 1345->1348 (#13749 API-key ownership on files and batches). No cap is raised beyond the merged LOC.", "_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "tests/integration/chat-pipeline.test.ts": 1736, "tests/unit/account-fallback-service.test.ts": 2056, - "tests/unit/batch_api.test.ts": 1345, + "tests/unit/batch_api.test.ts": 1348, "tests/unit/cc-compatible-provider.test.ts": 1225, "tests/unit/chatcore-translation-paths.test.ts": 3447, "tests/unit/chatgpt-web.test.ts": 4911, @@ -249,7 +250,7 @@ "tests/unit/executor-codex.test.ts": 1465, "tests/unit/executor-default-base.test.ts": 1632, "tests/unit/grok-web.test.ts": 2985, - "tests/unit/image-generation-handler.test.ts": 2133, + "tests/unit/image-generation-handler.test.ts": 2235, "tests/unit/models-catalog-route.test.ts": 1653, "tests/unit/perplexity-web.test.ts": 1384, "tests/unit/provider-models-route.test.ts": 1783, diff --git a/stryker.conf.json b/stryker.conf.json index 016657d32d..5045154583 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -250,6 +250,7 @@ "tests/unit/cursor-renewal.test.ts", "tests/unit/custom-model-target-format.test.ts", "tests/unit/daily-reset-dst-gap.test.ts", + "tests/unit/daily-reset-tz-threading.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", @@ -272,6 +273,7 @@ "tests/unit/follow-up-transcript.test.ts", "tests/unit/format-provider-error-cause.test.ts", "tests/unit/forwarded-header-budget.test.ts", + "tests/unit/free-badge-provider-gate.test.ts", "tests/unit/fusion-vision-panel-3378.test.ts", "tests/unit/gemini-deprecated-model-lockout.test.ts", "tests/unit/gemini-web-capabilities-9356.test.ts", @@ -294,6 +296,7 @@ "tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts", "tests/unit/search-432-plan-limit-cooldown.test.ts", "tests/unit/livews-forward-backoff-4604.test.ts", + "tests/unit/local-token-budget-429-skips-cooldown.test.ts", "tests/unit/management-auth-hardening.test.ts", "tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts", "tests/unit/masked-200-exhaustion-fallback-6427.test.ts", @@ -312,6 +315,7 @@ "tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts", "tests/unit/no-memory-header.test.ts", "tests/unit/noauth-autocombo-lockout-7623.test.ts", + "tests/unit/noauth-model-lockout.test.ts", "tests/unit/ollama-404-model-lockout-11071.test.ts", "tests/unit/non-streaming-client-translate.test.ts", "tests/unit/non-streaming-provider-leg.test.ts", diff --git a/tests/fixtures/dashboard-request-failed-redaction-probe.ts b/tests/fixtures/dashboard-request-failed-redaction-probe.ts index f8c5cab643..7108232285 100644 --- a/tests/fixtures/dashboard-request-failed-redaction-probe.ts +++ b/tests/fixtures/dashboard-request-failed-redaction-probe.ts @@ -98,7 +98,8 @@ async function main(): Promise { const writerDrained = await callLogs.waitForCallLogSaves(10_000); assert.equal(writerDrained, true, "call-log write must drain"); - const persisted = await callLogs.getCallLogById(callLogId); + // #13546: each attempt's row is keyed on traceId, not the shared pendingRequestId. + const persisted = await callLogs.getCallLogById(traceId); assert.ok(persisted, "failed attempt must still be available to internal diagnostics"); assert.equal(persisted.error, "Error: Provider failed in with api_key='[REDACTED]'"); assert.doesNotMatch(persisted.error, /sk-live-dashboard-secret|\/srv\/omniroute|\n/); diff --git a/tests/unit/attempt-logging-early-keepalive-merge.test.ts b/tests/unit/attempt-logging-early-keepalive-merge.test.ts index 2c346b4e22..0da0e0bea4 100644 --- a/tests/unit/attempt-logging-early-keepalive-merge.test.ts +++ b/tests/unit/attempt-logging-early-keepalive-merge.test.ts @@ -23,7 +23,11 @@ const { recordEarlyKeepaliveBytes, takeEarlyKeepaliveBytes } = await import("../../open-sse/utils/earlyKeepaliveByteBuffer.ts"); function baseCtx(overrides: Record = {}) { + // #13481/#13546: the call log row is keyed on traceId. It defaults to + // pendingRequestId so these tests keep polling by the id they pass in. + const pendingRequestId = (overrides.pendingRequestId as string) ?? "REPLACE"; return { + traceId: overrides.traceId ?? pendingRequestId, provider: "openai", connectionId: "conn-1", model: "gpt-x", @@ -48,13 +52,19 @@ function baseCtx(overrides: Record = {}) { } as Parameters[1]; } -async function pollForCallLog(id: string, tries = 120) { - for (let i = 0; i < tries; i++) { +// Wall-clock deadline instead of 120 tries x 20ms (2.4s): on a loaded runner the +// async SQLite write routinely outlasts that ceiling and the row reads as missing. +// Same budget and rationale as tests/unit/video-bridge-log-redaction.test.ts. +const POLL_DEADLINE_MS = 30_000; + +async function pollForCallLog(id: string, deadlineMs = POLL_DEADLINE_MS) { + const deadline = Date.now() + deadlineMs; + for (;;) { const row = await getCallLogById(id); if (row) return row as Record; + if (Date.now() >= deadline) return null; await new Promise((r) => setTimeout(r, 20)); } - return null; } before(async () => { diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index addd8561df..ef03c3017c 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1093,9 +1093,9 @@ test("v1 models catalog does not duplicate custom Jina specialty models", async assert.equal(response.status, 200); assert.equal(visibleJinaEmbeddingRows.length, 1); - assert.equal(visibleJinaEmbeddingRows[0].id, "jina/jina-embeddings-v5-text-small"); + assert.equal(visibleJinaEmbeddingRows[0].id, "jina-ai/jina-embeddings-v5-text-small"); assert.equal(visibleJinaRerankRows.length, 1); - assert.equal(visibleJinaRerankRows[0].id, "jina/jina-reranker-v3"); + assert.equal(visibleJinaRerankRows[0].id, "jina-ai/jina-reranker-v3"); }); test("v1 models catalog exposes image model input and output modalities for advanced image providers", async () => { diff --git a/tests/unit/paid-model-target-6540.test.ts b/tests/unit/paid-model-target-6540.test.ts index d8783f975b..cdd6d8e2f7 100644 --- a/tests/unit/paid-model-target-6540.test.ts +++ b/tests/unit/paid-model-target-6540.test.ts @@ -7,7 +7,7 @@ test("isPaidModelTarget — documented free model → 'free'", () => { }); test("isPaidModelTarget — provider in free catalog but model not listed free → 'paid'", () => { - assert.equal(isPaidModelTarget("together/Qwen/Qwen3-235B-A22B"), "paid"); + assert.equal(isPaidModelTarget("gemini/gemini-3.1-pro-preview"), "paid"); }); test("isPaidModelTarget — no separator (combo/alias name) → 'unknown' (fail open)", () => { diff --git a/tests/unit/paid-model-target-routes-6540.test.ts b/tests/unit/paid-model-target-routes-6540.test.ts index 5e6181f629..e338209fc7 100644 --- a/tests/unit/paid-model-target-routes-6540.test.ts +++ b/tests/unit/paid-model-target-routes-6540.test.ts @@ -14,10 +14,13 @@ const settingsRoute = await import("../../src/app/api/settings/route.ts"); const comboDefaultsRoute = await import("../../src/app/api/settings/combo-defaults/route.ts"); const backgroundDegradationRoute = await import("../../src/app/api/settings/background-degradation/route.ts"); +const { isPaidModelTarget } = await import("../../src/shared/utils/freeModels.ts"); // A provider present in the free-model catalog (so providerHasFreeModels is -// true) but a model id that is NOT one of its documented free models. -const PAID_TARGET = "together/Qwen/Qwen3-235B-A22B"; +// true) but a model id that is NOT one of its documented free models. Gemini's +// free tier is recurring and excludes the Pro line; the previous Together target +// stopped classifying once its one-time signup credit left the catalog (#13407). +const PAID_TARGET = "gemini/gemini-3.1-pro-preview"; // A documented free model. const FREE_TARGET = "openrouter/auto"; // No "/" or "," — a combo/alias name, fails open ("unknown"). @@ -38,6 +41,12 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); +test("fixtures classify as intended against the current free-model catalog", () => { + assert.equal(isPaidModelTarget(PAID_TARGET), "paid", "PAID_TARGET must stay a paid model"); + assert.equal(isPaidModelTarget(FREE_TARGET), "free", "FREE_TARGET must stay a free model"); + assert.equal(isPaidModelTarget(UNKNOWN_TARGET), "unknown"); +}); + // ── PATCH /api/settings — webSearchRouteModel ────────────────────────────── test("PATCH /api/settings blocks a paid webSearchRouteModel when hidePaidModels is on", async () => { diff --git a/tests/unit/video-bridge-log-redaction.test.ts b/tests/unit/video-bridge-log-redaction.test.ts index d828cb31a4..0d9ca50c9a 100644 --- a/tests/unit/video-bridge-log-redaction.test.ts +++ b/tests/unit/video-bridge-log-redaction.test.ts @@ -60,7 +60,11 @@ function videoBody() { } function baseCtx(overrides: Record = {}) { + // #13481/#13546: the call log row is keyed on traceId. It defaults to + // pendingRequestId so these tests keep polling by the id they pass in. + const pendingRequestId = (overrides.pendingRequestId as string) ?? "REPLACE"; return { + traceId: overrides.traceId ?? pendingRequestId, provider: "openai", connectionId: "conn-1", model: "gpt-x",