From 07d1816a45e2027c4bb1bbcea9857631e0ec4916 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 23 Aug 2026 22:35:29 -0300 Subject: [PATCH 01/78] fix(providers): hidden models leak into GET /v1/models (#11300) (#11309) Merging --admin: only fails are ESLint warnings ratchet drift (inherited) and dast-smoke (advisory, isRequired:null). Zero overlap with this PR's file scope (src/app/api/v1/models/catalog.ts). --- src/app/api/v1/models/catalog.ts | 53 +++++- ...hidden-models-leak-v1-models-11300.test.ts | 177 ++++++++++++++++++ 2 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 tests/unit/hidden-models-leak-v1-models-11300.test.ts diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 9a2252d9ee..78fd327009 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -265,10 +265,6 @@ async function buildUnifiedModelsResponseCore( // try would let a crash here propagate as an unhandled rejection instead // (catalogCache.ts's in-flight coalescing does not fully consume rejections). const hiddenModelsByProvider = getHiddenModelsByProvider(); - const isModelHiddenBulk = (providerId: string, modelId: string): boolean => { - const hiddenSet = hiddenModelsByProvider.get(providerId); - return hiddenSet ? hiddenSet.has(modelId) : false; - }; let settings: Record = {}; try { settings = await getSettings(); @@ -377,6 +373,35 @@ async function buildUnifiedModelsResponseCore( const resolvePublicOwnerId = (providerId: string, canonicalProviderId: string): string => providerIdToPrefix[providerId] || canonicalProviderId; + // #11300: the visibility toggle on a provider's dashboard page persists the + // hidden-model row under whatever key the route's `[id]` param happened to be + // (a node UUID, an alias like `cc`/`gh`/`cx`, or a canonical provider id) — + // see `PATCH /api/provider-models`. The catalog loops below each key their own + // lookup differently (raw connection provider, canonical id, or alias), so a + // single-key lookup missed the override whenever the write key and the read key + // diverged. Check every key a model could plausibly have been hidden under: + // the raw key passed in, its resolved canonical provider id, that canonical id's + // alias, and the compatible-provider-node prefix for either. + const isModelHiddenBulk = ( + providerKey: string | null | undefined, + modelId: string, + canonicalProviderId?: string | null + ): boolean => { + if (!providerKey || !modelId) return false; + const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey); + const alias = + providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined; + const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical]; + const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter( + (k): k is string => Boolean(k) + ); + for (const key of keysToCheck) { + const hiddenSet = hiddenModelsByProvider.get(key); + if (hiddenSet?.has(modelId)) return true; + } + return false; + }; + // Get combos let combos = []; await yieldCatalogBuildTurn(); @@ -955,7 +980,7 @@ async function buildUnifiedModelsResponseCore( if (!isModelSelectable(canonicalProviderId, model.id)) continue; if (!providerSupportsModel(canonicalProviderId, model.id)) continue; const aliasId = `${alias}/${model.id}`; - if (isModelHiddenBulk(canonicalProviderId, model.id)) continue; + if (isModelHiddenBulk(alias, model.id, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue; if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing)) continue; @@ -1018,7 +1043,15 @@ async function buildUnifiedModelsResponseCore( for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) { if (!providerSupportsModel("codex", modelId)) continue; - if (isModelHiddenBulk("codex", modelId)) continue; + // #11300: a codex-native unprefixed model can also be hidden via the + // `openai` provider page (codex runs on the openai-compatible connection) + // or via the `cx` alias — check all three so a hide from any of them + // suppresses the bare model id here. + if ( + isModelHiddenBulk("codex", modelId) || + isModelHiddenBulk("openai", modelId) + ) + continue; const alias = providerIdToAlias.codex || "cx"; const aliasId = `${alias}/${modelId}`; @@ -1079,7 +1112,7 @@ async function buildUnifiedModelsResponseCore( if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) { continue; } - if (isModelHiddenBulk(providerId, sm.id)) continue; + if (isModelHiddenBulk(providerId, sm.id, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, sm.id)) continue; // #6457: some upstream discovery catalogs (e.g. HuggingFace's live // `/v1/models`) return image/diffusion models with no modality info, @@ -1498,7 +1531,7 @@ async function buildUnifiedModelsResponseCore( if (!isUnifiedChatSourceModelSelectable(canonicalProviderId, { ...model, id: modelId })) continue; if (model.isHidden === true) continue; - if (isModelHiddenBulk(canonicalProviderId, modelId)) continue; + if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to user-defined custom rows too. // Custom entries do not carry pricing, so shouldHidePaid() decides @@ -1682,7 +1715,7 @@ async function buildUnifiedModelsResponseCore( continue; } - if (isModelHiddenBulk(canonicalProviderId, modelId)) continue; + if (isModelHiddenBulk(providerKey, modelId, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to alias-backed rows too. Alias mappings // point at providerKey/modelId with no pricing, so shouldHidePaid() @@ -1756,7 +1789,7 @@ async function buildUnifiedModelsResponseCore( for (const model of fallbackModels) { const modelId = typeof model.id === "string" ? model.id : null; if (!modelId) continue; - if (isModelHiddenBulk(canonicalProviderId, modelId)) continue; + if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue; if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue; // #6328: apply hidePaidModels to managed-fallback rows too. Compatible // provider fallbacks lack pricing; shouldHidePaid() decides via the diff --git a/tests/unit/hidden-models-leak-v1-models-11300.test.ts b/tests/unit/hidden-models-leak-v1-models-11300.test.ts new file mode 100644 index 0000000000..c937d2464e --- /dev/null +++ b/tests/unit/hidden-models-leak-v1-models-11300.test.ts @@ -0,0 +1,177 @@ +/** + * #11300 — Models toggled to "Hidden" on Provider pages are still listed in + * `GET /v1/models`. + * + * `PATCH /api/provider-models?provider=&modelId=` persists the hidden + * override under whatever key the dashboard's `[id]` route param happened to be + * (an alias like `cc`/`gh`/`cx`, a canonical provider id, a compatible-provider + * node UUID, or its configured prefix). `catalog.ts`'s `isModelHiddenBulk()` did + * a single-key lookup, so a model stayed listed in `/v1/models` whenever the key + * used to READ diverged from the key used to WRITE: + * + * - Static `PROVIDER_MODELS` loop checked only `canonicalProviderId` — a model + * hidden under the alias (e.g. `cc` for Claude Code) never matched. + * - The Codex-native-unprefixed loop checked only `"codex"` — a model hidden + * via the `openai` provider page (codex often shares the openai-compatible + * connection) never matched. + * - The synced-discovery loop checked only the raw connection `providerId` — + * a model hidden via the compatible-provider node's configured *prefix* + * (the identifier the operator actually sees/uses on that node's page) + * never matched. + */ +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-11300-hidden-leak-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const { mergeModelCompatOverride } = await import("../../src/lib/localDb.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function fetchCatalogIds(): Promise { + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: Array<{ id: string }> }; + assert.ok(Array.isArray(body.data), "response has data array"); + return body.data.map((m) => m.id); +} + +test("#11300 A: hiding a static model under its ALIAS (cc) excludes it under both cc/ and claude/ ids", async () => { + await providersDb.createProviderConnection({ + provider: "claude", + authType: "apikey", + name: "claude-main", + apiKey: "sk-test-11300a", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + // Sanity: before hiding, the model is advertised. + let ids = await fetchCatalogIds(); + assert.ok( + ids.includes("cc/claude-opus-5"), + `expected cc/claude-opus-5 to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}` + ); + + // Operator hides the model on the provider page, whose route param is the + // alias "cc" (not the canonical "claude"). + mergeModelCompatOverride("cc", "claude-opus-5", { isHidden: true }); + + ids = await fetchCatalogIds(); + assert.ok( + !ids.includes("cc/claude-opus-5"), + `#11300 RED: cc/claude-opus-5 hidden under alias "cc" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}` + ); + assert.ok( + !ids.includes("claude/claude-opus-5"), + `#11300 RED: claude/claude-opus-5 hidden under alias "cc" must not appear either` + ); +}); + +test("#11300 B: hiding a codex-native unprefixed model under \"openai\" excludes the bare model id", async () => { + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-main", + apiKey: "sk-test-11300b", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const nativeModelId = "gpt-5.6-sol"; + + let ids = await fetchCatalogIds(); + assert.ok( + ids.includes(nativeModelId), + `expected bare "${nativeModelId}" to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}` + ); + + // Hidden via the "openai" provider page (codex native models are commonly + // reached through the shared openai-compatible connection). + mergeModelCompatOverride("openai", nativeModelId, { isHidden: true }); + + ids = await fetchCatalogIds(); + assert.ok( + !ids.includes(nativeModelId), + `#11300 RED: bare "${nativeModelId}" hidden under "openai" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}` + ); +}); + +test("#11300 C: hiding a compatible-node synced model under its configured PREFIX excludes prefix/", async () => { + const NODE_ID = "openai-compatible-chat-11300-c0ffee00-0000-4000-8000-000000000000"; + const PREFIX = "deepseek-node-11300"; + + await providersDb.createProviderNode({ + id: NODE_ID, + type: "openai-compatible", + name: "Deepseek Node (11300 probe)", + prefix: PREFIX, + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + const connection = await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + name: "deepseek-node-conn", + apiKey: "sk-test-11300c", + isActive: true, + testStatus: "active", + providerSpecificData: { + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }, + }); + + const modelId = "deepseek-v4-flash-0731"; + await modelsDb.replaceSyncedAvailableModelsForConnection(NODE_ID, (connection as { id: string }).id, [ + { id: modelId, name: "DeepSeek V4 Flash", source: "imported", supportedEndpoints: ["chat"] }, + ]); + + let ids = await fetchCatalogIds(); + assert.ok( + ids.includes(`${PREFIX}/${modelId}`), + `expected ${PREFIX}/${modelId} to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}` + ); + + // Operator hides the model via the node's page, which is keyed by the + // configured prefix rather than the internal node UUID. + mergeModelCompatOverride(PREFIX, modelId, { isHidden: true }); + + ids = await fetchCatalogIds(); + assert.ok( + !ids.includes(`${PREFIX}/${modelId}`), + `#11300 RED: ${PREFIX}/${modelId} hidden under prefix "${PREFIX}" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}` + ); + assert.ok( + !ids.includes(`${NODE_ID}/${modelId}`), + `#11300 RED: ${NODE_ID}/${modelId} hidden under prefix "${PREFIX}" must not appear either` + ); +}); From ac02c5b42fdefde89ac4237d22e97a1f04fad56e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 23 Aug 2026 22:55:33 -0300 Subject: [PATCH 02/78] fix(resilience): don't clear an active rate-limit cooldown for non-quota_exhausted errors (#11277) (#11310) Merging --admin: only fails are ESLint warnings ratchet drift (inherited base-red) and dast-smoke (advisory, isRequired:null). Zero overlap with this PR's scope (src/lib/usage/providerLimits.ts). --- src/lib/usage/providerLimits.ts | 19 ++++ tests/unit/provider-limits-recovery.test.ts | 99 ++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 7e8811a9e9..ae39205674 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -499,6 +499,25 @@ export async function maybeClearRecoveredQuotaState( // the previous synthetic-cooldown guard. return connection; } + } else if ( + connection.rateLimitedUntil && + new Date(connection.rateLimitedUntil).getTime() > Date.now() + ) { + // Universal fallback guard for every lastErrorType other than + // "quota_exhausted" (which gets the more precise per-window check above, + // and may legitimately release early once the REAL window has reset even + // while a synthetic rateLimitedUntil is still in the future). A future + // rateLimitedUntil is a hard statement made by the 429/error handler that + // persisted it (src/sse/services/auth.ts, src/app/api/providers/[id]/test/ + // route.ts) — no quota poll finding *some* usable window elsewhere should + // be able to overrule it. Before this fix, ANY lastErrorType other than + // "quota_exhausted" skipped straight to hasTransientState/ + // clearRecoveredProviderState() below with no rateLimitedUntil check at + // all, so a multi-day cooldown (observed: 146h, Z.AI weekly quota) got + // cleared on the very next quota sync a few minutes later — a + // self-restart/burn loop that kept burning real upstream calls against a + // known-exhausted connection (#11277). + return connection; } const hasTransientState = diff --git a/tests/unit/provider-limits-recovery.test.ts b/tests/unit/provider-limits-recovery.test.ts index f6f275525b..eb2bcd0ea6 100644 --- a/tests/unit/provider-limits-recovery.test.ts +++ b/tests/unit/provider-limits-recovery.test.ts @@ -83,7 +83,25 @@ test.after(async () => { }); test("successful GLM quota refresh clears transient rate-limit state", async () => { - const connection = await createGlmConnectionWithTransientCooldown(); + // The cooldown must already be EXPIRED for a successful refresh to clear it + // (#11277: a rateLimitedUntil still in the future is a hard statement from + // the error handler that persisted it — no quota poll may overrule it, + // regardless of lastErrorType). Before #11277's fix this test used a + // still-future rateLimitedUntil and asserted it got cleared anyway, which + // was the same defect class as the reported bug, just a shorter window. + const connection = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: `GLM Recovery ${Date.now()}`, + apiKey: "glm-test-key", + testStatus: "unavailable", + rateLimitedUntil: new Date(Date.now() - 60_000).toISOString(), + lastError: "rate limit exceeded", + lastErrorType: "rate_limited", + lastErrorSource: "executor", + errorCode: 429, + backoffLevel: 2, + }); const connectionId = (connection as { id: string }).id; await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => { @@ -101,6 +119,39 @@ test("successful GLM quota refresh clears transient rate-limit state", async () assert.equal(updated.backoffLevel, 0, "backoffLevel should be reset to 0"); }); +test("a still-future rateLimitedUntil is not cleared by a successful quota refresh, regardless of lastErrorType (#11277)", async () => { + const stillFutureRateLimitedUntil = new Date(Date.now() + 60_000).toISOString(); + const connection = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: `GLM Still Cooling ${Date.now()}`, + apiKey: "glm-test-key", + testStatus: "unavailable", + rateLimitedUntil: stillFutureRateLimitedUntil, + lastError: "rate limit exceeded", + lastErrorType: "rate_limited", + lastErrorSource: "executor", + errorCode: 429, + backoffLevel: 2, + }); + const connectionId = (connection as { id: string }).id; + + await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => { + await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); + }); + + const updated = (await providersDb.getProviderConnectionById(connectionId)) as Record< + string, + unknown + >; + assert.equal( + updated.testStatus, + "unavailable", + "an active cooldown must stay locked even though the quota fetch succeeded" + ); + assert.equal(updated.rateLimitedUntil, stillFutureRateLimitedUntil); +}); + async function createGlmConnectionWithStatus(status: string) { return providersDb.createProviderConnection({ provider: "glm", @@ -334,6 +385,52 @@ test("Claude subscription quota still exhausted keeps the connection locked (no assert.equal(after.rateLimitedUntil, syntheticRateLimitedUntil); }); +test("rate_limit_exceeded cooldown is not cleared early by an unrelated quota window looking usable (#11277)", async () => { + // Reproduces #11277: a connection-scoped cooldown persisted with + // lastErrorType "rate_limit_exceeded" (RateLimitReason.RATE_LIMIT_EXCEEDED) + // and a long rateLimitedUntil (derived from an upstream reset hint — the + // reported production case was ~146h) must NOT be cleared just because the + // next scheduled quota sync reports hasUsableQuota()===true from some + // unrelated window. Before the fix, only lastErrorType==="quota_exhausted" + // reached the rateLimitedUntil guard, so every other reason (including + // rate_limit_exceeded) skipped straight to clearRecoveredProviderState(), + // producing a self-restart/burn loop on a multi-day cooldown. + const farFutureRateLimitedUntil = new Date(Date.now() + 146 * 60 * 60 * 1000).toISOString(); + const created = await providersDb.createProviderConnection({ + provider: "opencode", + authType: "apikey", + name: `OpenCode RateLimitExceeded ${Date.now()}`, + apiKey: "opencode-test-key", + testStatus: "unavailable", + isActive: true, + lastError: "Account quota exhausted (opencode)", + lastErrorType: "rate_limit_exceeded", + errorCode: 429, + rateLimitedUntil: farFutureRateLimitedUntil, + backoffLevel: 1, + }); + const connectionId = (created as { id: string }).id; + const connection = await providersDb.getProviderConnectionById(connectionId); + + // No `quotas` object at all (degraded/partial fetch shape) — this is the + // exact shape that, pre-fix, fell straight through to hasTransientState + // and cleared the cooldown for any lastErrorType other than quota_exhausted. + const result = await providerLimits.maybeClearRecoveredQuotaState(connection, { + quotas: { unrelated: { unlimited: true } }, + }); + + assert.equal( + result.testStatus, + "unavailable", + "an active rate_limit_exceeded cooldown must stay locked" + ); + + const after = await providersDb.getProviderConnectionById(connectionId); + assert.equal(after.testStatus, "unavailable"); + assert.equal(after.lastErrorType, "rate_limit_exceeded"); + assert.equal(after.rateLimitedUntil, farFutureRateLimitedUntil); +}); + test("CAS primitive clears when expected state matches", async () => { const created = await createGlmConnectionWithTransientCooldown(); const connectionId = (created as { id: string }).id; From adca3b881c8a643ed9cd60b2ba4b4e3c4418acbd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 24 Aug 2026 01:10:23 -0300 Subject: [PATCH 03/78] fix(kie): map remaining google-imagen Market ids to their real KIE upstream ids (#11326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging --admin with red discrimination (merge-gates §4). Fails: ESLint warnings ratchet drift (inherited base-red), Unit Tests shards containing stream-timing.test.ts (CPU-contention timing flake, assert.ok(total >= 15)ms — unrelated to this PR's scope, open-sse/handlers/imageGeneration.ts), and dast-smoke (advisory, isRequired:null). --- .../11326-kie-market-google-imagen-ids.md | 1 + open-sse/handlers/imageGeneration.ts | 12 ++++ ...kie-market-upstream-model-id-11225.test.ts | 57 +++++++++++++++++-- 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/11326-kie-market-google-imagen-ids.md diff --git a/changelog.d/fixes/11326-kie-market-google-imagen-ids.md b/changelog.d/fixes/11326-kie-market-google-imagen-ids.md new file mode 100644 index 0000000000..62dacb5d48 --- /dev/null +++ b/changelog.d/fixes/11326-kie-market-google-imagen-ids.md @@ -0,0 +1 @@ +- **fix(kie):** map the remaining `google-imagen/*` KIE Market catalog ids (`nano-banana`, `nano-banana-pro`, `nano-banana-edit`) to their real, KIE-documented upstream `model` values — `#11225`'s fix only covered `nano-banana-2` ([#11326](https://github.com/diegosouzapw/OmniRoute/pull/11326)). diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index fde2f4a403..db0745898e 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -91,8 +91,20 @@ interface KieImageOptions { } | null; } +// KIE Market catalog ids are namespaced for OmniRoute's catalog +// (`google-imagen/`), but the KIE Market createTask API expects +// vendor-specific upstream ids that do not follow a single consistent +// pattern (confirmed against docs.kie.ai/market/google/* — see #11225, +// #11296): nano-banana-2 and nano-banana-pro drop the vendor namespace +// entirely, while nano-banana and nano-banana-edit use a `google/` prefix +// instead of `google-imagen/`. Every other KIE Market namespace (seedream, +// flux, ideogram, qwen, wan, grok-imagine, gpt) already matches its real +// upstream id byte-for-byte, so this map stays scoped to google-imagen. export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap = new Map([ + ["google-imagen/nano-banana", "google/nano-banana"], ["google-imagen/nano-banana-2", "nano-banana-2"], + ["google-imagen/nano-banana-pro", "nano-banana-pro"], + ["google-imagen/nano-banana-edit", "google/nano-banana-edit"], ]); export function resolveKieMarketUpstreamModelId(publicModelId: string): string { diff --git a/tests/unit/kie-market-upstream-model-id-11225.test.ts b/tests/unit/kie-market-upstream-model-id-11225.test.ts index bc9484494e..533d0d051f 100644 --- a/tests/unit/kie-market-upstream-model-id-11225.test.ts +++ b/tests/unit/kie-market-upstream-model-id-11225.test.ts @@ -103,7 +103,7 @@ function resolveLiveKieMarketCatalog() { })); } -test("KIE Market resolver changes exactly one id in the live market catalog", () => { +test("KIE Market resolver changes exactly the 4 google-imagen ids in the live market catalog", () => { const roundTrips = resolveLiveKieMarketCatalog(); const changed = roundTrips.filter(({ publicModelId, upstreamModelId }) => { return upstreamModelId !== publicModelId; @@ -114,12 +114,31 @@ test("KIE Market resolver changes exactly one id in the live market catalog", () publicModelId: "google-imagen/nano-banana-2", upstreamModelId: "nano-banana-2", }, + { + publicModelId: "google-imagen/nano-banana", + upstreamModelId: "google/nano-banana", + }, + { + publicModelId: "google-imagen/nano-banana-pro", + upstreamModelId: "nano-banana-pro", + }, + { + publicModelId: "google-imagen/nano-banana-edit", + upstreamModelId: "google/nano-banana-edit", + }, ]); }); +const REWRITTEN_GOOGLE_IMAGEN_MARKET_IDS = new Set([ + "google-imagen/nano-banana", + "google-imagen/nano-banana-2", + "google-imagen/nano-banana-pro", + "google-imagen/nano-banana-edit", +]); + test("KIE Market resolver preserves every other live market catalog id byte-identically", () => { for (const { publicModelId, upstreamModelId } of resolveLiveKieMarketCatalog()) { - if (publicModelId !== "google-imagen/nano-banana-2") { + if (!REWRITTEN_GOOGLE_IMAGEN_MARKET_IDS.has(publicModelId)) { assert.equal( upstreamModelId, publicModelId, @@ -129,8 +148,8 @@ test("KIE Market resolver preserves every other live market catalog id byte-iden } }); -test("KIE Market resolver keeps exactly one explicit upstream id mapping", () => { - assert.equal(KIE_MARKET_UPSTREAM_MODEL_IDS.size, 1); +test("KIE Market resolver keeps exactly the explicit google-imagen upstream id mappings (#11296)", () => { + assert.equal(KIE_MARKET_UPSTREAM_MODEL_IDS.size, 4); }); test("KIE Market resolver passes an unknown namespaced id through byte-identically", () => { @@ -160,6 +179,36 @@ test("KIE Market createTask sends the bare upstream model id for Nano Banana 2 ( assert.equal(captured.result.data.data[0].url, "https://example.com/kie-market-image.png"); }); +test("KIE Market createTask sends the KIE upstream id for Nano Banana (#11296)", async () => { + const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana"); + + assert.equal( + captured.create.body.model, + "google/nano-banana", + "KIE Market createTask must send the KIE-documented google/nano-banana upstream id" + ); +}); + +test("KIE Market createTask sends the bare upstream model id for Nano Banana Pro (#11296)", async () => { + const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana-pro"); + + assert.equal( + captured.create.body.model, + "nano-banana-pro", + "KIE Market createTask must send the KIE-documented nano-banana-pro upstream id" + ); +}); + +test("KIE Market createTask sends the KIE upstream id for Nano Banana Edit (#11296)", async () => { + const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana-edit"); + + assert.equal( + captured.create.body.model, + "google/nano-banana-edit", + "KIE Market createTask must send the KIE-documented google/nano-banana-edit upstream id" + ); +}); + test("KIE Market createTask leaves genuinely namespaced upstream ids untouched (#11225 control)", async () => { const captured = await runKieMarketGeneration("kie/seedream/4.5-text-to-image"); From c3698eedcb8a0e7226fce0299d63ace03261f970 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 24 Aug 2026 01:49:32 -0300 Subject: [PATCH 04/78] fix(dashboard): route the Adapta tutorial CTA through the branded shortener (#11329) Validated on a 17-PR combined board: TSX parses clean, eslint clean. Adapta tutorial CTA href now points at the branded shortener (link.omniroute.online/adapta) while keeping the visible link text as the real domain. Completes #11196's shortener rollout. --- .../providers/[id]/components/AdaptaTutorialModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx index e01ab1ea2d..8882b7ab3c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx @@ -7,6 +7,10 @@ type AdaptaTutorialModalProps = { onClose: () => void; }; +// The Adapta CTA href points at https://link.omniroute.online/adapta (our own +// shortener, the `adapta` slug) so the click lands in our Kutt metrics. The visible +// link text intentionally stays the real domain (agent.adapta.one/agentic-chat) so +// users still see where they are going. export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) { const t = useTranslations("providers.adaptaTutorial"); @@ -29,7 +33,7 @@ export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProp

{t("step1DescPrefix")}{" "} Date: Mon, 24 Aug 2026 11:49:37 +0700 Subject: [PATCH 05/78] fix(security): refuse proxy-authorization and proxy-authenticate upstream (#11328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on a 17-PR combined board: upstream-headers-proxy-auth within the board's 287/287, typecheck:core clean, gates within baseline. proxy-authorization and proxy-authenticate join the FORBIDDEN denylist — forwarding proxy-authorization to a model provider would hand that provider the operator's own proxy credential. Thank you @ntdat812! --- .../11328-upstream-headers-proxy-auth.md | 1 + src/shared/constants/upstreamHeaders.ts | 10 +++ .../unit/upstream-headers-proxy-auth.test.ts | 66 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 changelog.d/fixes/11328-upstream-headers-proxy-auth.md create mode 100644 tests/unit/upstream-headers-proxy-auth.test.ts diff --git a/changelog.d/fixes/11328-upstream-headers-proxy-auth.md b/changelog.d/fixes/11328-upstream-headers-proxy-auth.md new file mode 100644 index 0000000000..2155b9ea90 --- /dev/null +++ b/changelog.d/fixes/11328-upstream-headers-proxy-auth.md @@ -0,0 +1 @@ +- **fix(security):** `proxy-authorization` and `proxy-authenticate` are refused as upstream/custom headers, so a proxy credential is no longer forwarded to the model provider — the canonical denylist now matches the RFC 7230 §6.1 set the rest of the codebase already strips ([#11328](https://github.com/diegosouzapw/OmniRoute/pull/11328)) diff --git a/src/shared/constants/upstreamHeaders.ts b/src/shared/constants/upstreamHeaders.ts index f4502aacfa..5d9d7f7f08 100644 --- a/src/shared/constants/upstreamHeaders.ts +++ b/src/shared/constants/upstreamHeaders.ts @@ -10,6 +10,16 @@ const FORBIDDEN = new Set( "content-length", "keep-alive", "proxy-connection", + // The two RFC 7230 §6.1 hop-by-hop names this list was missing. They belong + // to the connection between the client and OmniRoute (or its upstream + // proxy), never to the request OmniRoute makes to the model provider — + // forwarding `proxy-authorization` hands that proxy credential to the + // provider. `src/lib/services/reverseProxy.ts` (HOP_BY_HOP), + // `src/mitm/sanitizeHeaders.ts`, `src/mitm/inspector/httpProxyServer.ts`, + // `src/mitm/tproxy/tlsCapture.ts` and `src/app/api/openapi/try/route.ts` + // all already strip them; this list, the canonical one, did not. + "proxy-authenticate", + "proxy-authorization", "transfer-encoding", "te", "trailer", diff --git a/tests/unit/upstream-headers-proxy-auth.test.ts b/tests/unit/upstream-headers-proxy-auth.test.ts new file mode 100644 index 0000000000..6cca43ead0 --- /dev/null +++ b/tests/unit/upstream-headers-proxy-auth.test.ts @@ -0,0 +1,66 @@ +// `FORBIDDEN` in src/shared/constants/upstreamHeaders.ts is documented as the +// hop-by-hop / Host / framing denylist, and it was missing two of the RFC 7230 +// §6.1 names. Measured before the fix: +// +// proxy-authorization upstream=allow custom=allow +// proxy-authenticate upstream=allow custom=allow +// proxy-connection upstream=BLOCK custom=BLOCK +// +// `proxy-authorization` is the one that costs something: it authenticates the +// hop to the operator's own proxy, so forwarding it hands that credential to +// the model provider. Five other modules in this repo already strip it +// (reverseProxy HOP_BY_HOP, mitm/sanitizeHeaders, inspector/httpProxyServer, +// tproxy/tlsCapture, openapi/try) — the canonical list did not. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + isForbiddenUpstreamHeaderName, + isForbiddenCustomHeaderName, +} from "../../src/shared/constants/upstreamHeaders.ts"; +import { HOP_BY_HOP } from "../../src/lib/services/reverseProxy.ts"; +import { sanitizeUpstreamHeadersMap } from "../../src/lib/db/models.ts"; + +test("proxy-authorization and proxy-authenticate are refused", () => { + for (const name of ["proxy-authorization", "proxy-authenticate"]) { + assert.equal(isForbiddenUpstreamHeaderName(name), true, name); + assert.equal(isForbiddenCustomHeaderName(name), true, name); + } +}); + +test("the refusal is case-insensitive, like every other name in the list", () => { + for (const name of ["Proxy-Authorization", "PROXY-AUTHENTICATE", " Proxy-Authorization "]) { + assert.equal(isForbiddenUpstreamHeaderName(name), true, name); + } +}); + +test("sanitizeUpstreamHeadersMap drops them and keeps the rest", () => { + const out = sanitizeUpstreamHeadersMap({ + "Proxy-Authorization": "Basic c2VjcmV0", + "Proxy-Authenticate": "Basic realm=x", + "X-Custom": "ok", + }); + + assert.deepEqual(out, { "X-Custom": "ok" }); +}); + +test("the canonical list now covers every hop-by-hop name reverseProxy strips", () => { + // `reverseProxy.HOP_BY_HOP` is the repo's own RFC 7230 §6.1 list. The two + // lists drifting apart is what this fix repairs, so compare them directly — + // `trailers` is the TE token, spelled `trailer` as a header name. + const missing = [...HOP_BY_HOP] + .map((name) => (name === "trailers" ? "trailer" : name)) + .filter((name) => !isForbiddenUpstreamHeaderName(name)); + + assert.deepEqual(missing, []); +}); + +test("ordinary headers are still allowed", () => { + for (const name of ["x-custom", "x-forwarded-for", "user-agent", "accept"]) { + assert.equal(isForbiddenUpstreamHeaderName(name), false, name); + } + // Auth headers stay allowed as *upstream* headers (the credential layer owns + // them) while remaining forbidden as operator-supplied custom headers. + assert.equal(isForbiddenUpstreamHeaderName("authorization"), false); + assert.equal(isForbiddenCustomHeaderName("authorization"), true); +}); From 24ac71465eb2d7d6311567a19f14ac678ed3e4ac Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:49:42 +0200 Subject: [PATCH 06/78] test(db): make singleton reset survive the full suite and un-skip the 3 DB-state tests (#11327) Validated on a 17-PR combined board: capture-critical-db-state 7/7 (all three previously-skipped tests now run) within the board's 287/287, typecheck:core clean. Fixes the racy DATA_DIR-after-dynamic-import isolation and removes a duplicate type declaration. Thank you @pacocartones! --- tests/unit/capture-critical-db-state.test.ts | 75 +++++++++----------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/tests/unit/capture-critical-db-state.test.ts b/tests/unit/capture-critical-db-state.test.ts index 957c1afce3..6ded65793e 100644 --- a/tests/unit/capture-critical-db-state.test.ts +++ b/tests/unit/capture-critical-db-state.test.ts @@ -4,41 +4,33 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -type CoreModule = typeof import("../../src/lib/db/core.ts"); +// Single shared tempDir for all tests — DATA_DIR/SQLITE_FILE are module-level consts +// resolved once at first import, so we must create the temp dir and set DATA_DIR +// BEFORE importing core.ts. +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-test-")); +const originalDataDir = process.env.DATA_DIR; +process.env.DATA_DIR = tempDir; -// Shared across all tests — the module caches DATA_DIR / SQLITE_FILE at load time, -// so we must create the temp dir and import exactly once. -type CoreModule = typeof import("../../src/lib/db/core.ts"); -let tempDir: string; -let originalDataDir: string | undefined; -let getDbInstance: CoreModule["getDbInstance"]; -let resetDbInstance: CoreModule["resetDbInstance"]; -let ensureDbInitialized: CoreModule["ensureDbInitialized"]; -let closeDbInstance: CoreModule["closeDbInstance"]; +// Import resetDbInstance ONCE at the top with the same ESM specifier the tests use, +// so cleanup() operates on the real singleton (not a stale CJS require). +// This is the FIRST import of core.ts, so DATA_DIR resolves to our tempDir. +import { + getDbInstance, + resetDbInstance, + ensureDbInitialized, + closeDbInstance, +} from "../../src/lib/db/core.ts"; before(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-test-")); - originalDataDir = process.env.DATA_DIR; - process.env.DATA_DIR = tempDir; - - const core = await import("../../src/lib/db/core.ts"); - getDbInstance = core.getDbInstance; - resetDbInstance = core.resetDbInstance; - ensureDbInitialized = core.ensureDbInitialized; - closeDbInstance = core.closeDbInstance; - - // Clear any singleton left by a previous test file in the same shard + // Clear any singleton left by a previous test file in the same shard. closeDbInstance(); - // Create a fresh DB in the temp dir (handles async driver initialization) + // Create a fresh DB in the temp dir (handles async driver initialization). await ensureDbInitialized(); }); after(() => { - try { - resetDbInstance(); - } catch { - // ignore - } + // Let reset errors surface — no silent swallowing. + resetDbInstance(); if (originalDataDir !== undefined) { process.env.DATA_DIR = originalDataDir; } else { @@ -90,9 +82,9 @@ test("getDbInstance creates tables from SCHEMA_SQL (proves initialization succee // The preservedCriticalState sentinel is captureSucceeded: true on fresh DB // (no existing file = no corruption path = initialized with default sentinel). // Verify this indirectly: the DB is fully functional and migrations ran. - const migrationCount = db - .prepare("SELECT COUNT(*) as c FROM _omniroute_migrations") - .get() as { c: number }; + const migrationCount = db.prepare("SELECT COUNT(*) as c FROM _omniroute_migrations").get() as { + c: number; + }; assert.ok(migrationCount.c >= 1, "at least one migration should be recorded"); }); @@ -142,12 +134,14 @@ test("resetDbInstance clears the singleton so next call creates a new DB", async // Write a marker row so we can prove the post-reset handle reopens the same // on-disk file through a freshly opened connection (not the cached one). - db1.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( - "reset_ns", - "marker", - JSON.stringify({ v: 1 }) - ); + db1 + .prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run("reset_ns", "marker", JSON.stringify({ v: 1 })); + // Close the previous handle explicitly before resetting, so the file descriptor + // is released before the next reopen (POSIX allows open fds to survive fs.rmSync, + // but we want honest isolation, not accidental survival). + closeDbInstance(); resetDbInstance(); // Re-initialize after reset — drivers may need async pre-init (sql.js WASM) @@ -169,19 +163,14 @@ test("getDbInstance sets WAL journal mode", async () => { const db = getDbInstance(); const mode = db.pragma("journal_mode", { simple: true }) as string; - assert.equal( - String(mode).toLowerCase(), - "wal", - "on-disk DB should open in WAL journal mode" - ); + assert.equal(String(mode).toLowerCase(), "wal", "on-disk DB should open in WAL journal mode"); }); test("getDbInstance stores schema_version in db_meta", async () => { const db = getDbInstance(); - const row = db - .prepare("SELECT value FROM db_meta WHERE key = 'schema_version'") - .get() as { value: string } | undefined; + const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as + { value: string } | undefined; assert.ok(row, "db_meta should hold a schema_version row after init"); assert.equal(row.value, "1", "schema_version should be seeded to '1'"); }); From 04b2c479406b520448e6db358be8cbaf064f3f78 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Mon, 24 Aug 2026 11:49:48 +0700 Subject: [PATCH 07/78] fix(i18n): restore three placeholders dropped from the pt catalogue (#11325) Validated on a 17-PR combined board: i18n-placeholder-parity within the board's 287/287, typecheck:core clean. Restores 3 dropped placeholders in pt.json (the visible one: the cache tile's subtitle was repeating its own label instead of showing the total) and adds a 42-locale placeholder-set gate so this class of drift can't recur silently. Thank you @ntdat812! --- .../fixes/11325-i18n-pt-placeholder-parity.md | 1 + src/i18n/messages/pt.json | 6 +- tests/unit/i18n-placeholder-parity.test.ts | 94 +++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/11325-i18n-pt-placeholder-parity.md create mode 100644 tests/unit/i18n-placeholder-parity.test.ts diff --git a/changelog.d/fixes/11325-i18n-pt-placeholder-parity.md b/changelog.d/fixes/11325-i18n-pt-placeholder-parity.md new file mode 100644 index 0000000000..97a2370955 --- /dev/null +++ b/changelog.d/fixes/11325-i18n-pt-placeholder-parity.md @@ -0,0 +1 @@ +- **fix(i18n):** three `pt` strings had dropped their placeholders — the cache tile's subtitle repeated its own label instead of showing `{total}` — and a unit test now enforces placeholder parity with `en` across all locales ([#11325](https://github.com/diegosouzapw/OmniRoute/pull/11325)) diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3194c7e492..a20bd38f1d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4242,7 +4242,7 @@ "smokeSendSuccessWithTask": "message/send ok (tarefa {taskId}).", "smokeSendSuccess": "message/send ok.", "smokeStreamFailed": "Teste de fumo message/stream falhou.", - "smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}).", + "smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}{stateSuffix}).", "smokeStreamNoTaskId": "message/stream terminou sem ID de tarefa.", "health": "Estado de saúde", "ok": "OK", @@ -10202,7 +10202,7 @@ "scanning": "A analisar...", "opencodeIntegration": "Integração OpenCode", "opencodeDetected": "opencode {version} detetado", - "opencodeDesc": "Gera um {configFile} pronto a usar com a tua configuração OmniRoute", + "opencodeDesc": "Gera um {configFile} pronto a usar com o URL base do OmniRoute e todos os modelos disponíveis — coloca-o na raiz do teu projeto e executa {command}.", "downloadConfig": "Descarregar {file}", "downloaded": "Descarregado!", "setupGuideTitle": "Guia de configuração", @@ -10395,7 +10395,7 @@ "dbEntries": "Entradas na BD", "dbEntriesSub": "Persistido (SQLite)", "cacheHits": "Acertos de cache", - "cacheHitsSub": "Acertos", + "cacheHitsSub": "de {total} no total", "tokensSaved": "Tokens Poupançados", "tokensSavedSub": "Estimado a partir de acertos", "hitRate": "Taxa de acertos", diff --git a/tests/unit/i18n-placeholder-parity.test.ts b/tests/unit/i18n-placeholder-parity.test.ts new file mode 100644 index 0000000000..fcb4f75e79 --- /dev/null +++ b/tests/unit/i18n-placeholder-parity.test.ts @@ -0,0 +1,94 @@ +// A translation that drops a placeholder silently loses the value it carried: +// the string still renders, just without the number, path or command the +// English copy promised. Nothing checked for that, and three strings had +// drifted (all in `pt`): +// +// a2aDashboard.smokeStreamSuccessWithTask lost {stateSuffix} +// agents.opencodeDesc lost {command} +// cache.cacheHitsSub lost {total} ("of {total} total" -> "Acertos") +// +// Placeholder sets are compared, not counts or order: a locale may reorder or +// repeat them, but it may not introduce one English never defined (it would +// render literally) or drop one (its value disappears). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const messagesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "src", + "i18n", + "messages" +); + +type Json = { [key: string]: string | Json }; + +function loadLocale(file: string): Json { + return JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")) as Json; +} + +function flatten(value: Json, prefix = ""): Map { + const out = new Map(); + for (const [key, child] of Object.entries(value)) { + const dotted = prefix ? `${prefix}.${key}` : key; + if (typeof child === "string") out.set(dotted, child); + else if (child && typeof child === "object") { + for (const [k, v] of flatten(child, dotted)) out.set(k, v); + } + } + return out; +} + +/** + * Names an ICU message interpolates: `{name}` and the argument of a typed + * placeholder such as `{count, plural, ...}`. Nested sub-messages are covered + * because the scan is a plain sweep of the whole string. + */ +function placeholders(message: string): Set { + return new Set( + [...message.matchAll(/\{\s*([a-zA-Z0-9_]+)\s*[,}]/g)].map((match) => match[1]) + ); +} + +const english = flatten(loadLocale("en.json")); +const locales = readdirSync(messagesDir) + .filter((file) => file.endsWith(".json") && file !== "en.json") + .sort(); + +test("every locale keeps the placeholders its English source defines", () => { + const drift: string[] = []; + + for (const file of locales) { + for (const [key, translated] of flatten(loadLocale(file))) { + const source = english.get(key); + if (typeof source !== "string") continue; + + const expected = placeholders(source); + const actual = placeholders(translated); + const missing = [...expected].filter((name) => !actual.has(name)); + const unknown = [...actual].filter((name) => !expected.has(name)); + if (missing.length === 0 && unknown.length === 0) continue; + + drift.push( + `${file} ${key}\n` + + ` en: ${source}\n` + + ` ${file.replace(".json", "")}: ${translated}\n` + + ` missing=[${missing.join(", ")}] unknown=[${unknown.join(", ")}]` + ); + } + } + + assert.deepEqual(drift, [], `\n placeholder drift:\n ${drift.join("\n ")}\n`); +}); + +test("the checker itself recognises the drift it is meant to catch", () => { + // Without this the test above could pass by never matching anything. + assert.deepEqual([...placeholders("of {total} total")], ["total"]); + assert.deepEqual([...placeholders("ok (task {taskId}{stateSuffix}).")], ["taskId", "stateSuffix"]); + assert.deepEqual([...placeholders("{count, plural, one {# item} other {# items}}")], ["count"]); + assert.deepEqual([...placeholders("Acertos")], []); +}); From 79f8ae9d1ecbbda6f1ef8302dff0d9ef05279eb8 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:49:52 +0200 Subject: [PATCH 08/78] fix(i18n): add the 3 pt-BR CLI keys that break the locale parity test (#11322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on a 17-PR combined board: typecheck:core clean, gates within baseline. Restores 3 missing pt-BR CLI keys (setup.opencode, serve.tls_cert, serve.tls_key) — parity restored, 823/823. Thank you @pacocartones! --- bin/cli/locales/pt-BR.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index c821bf976c..eec1b42a0e 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -26,7 +26,8 @@ "testFailed": "Teste do provedor falhou: {error}", "loginEnabled": "Login: habilitado (senha atualizada)", "loginDisabled": "Login: desabilitado", - "providerInfo": "Provedor: {info}" + "providerInfo": "Provedor: {info}", + "opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode" }, "doctor": { "title": "OmniRoute Doctor", @@ -254,7 +255,9 @@ "no_recovery": "Desabilitar reinício automático em crash (modo debug)", "max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)", "tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)", - "no_tray": "Desabilitar ícone na bandeja do sistema" + "no_tray": "Desabilitar ícone na bandeja do sistema", + "tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)", + "tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)" }, "backup": { "title": "Backup", From 6984676d95880796be8a6b5fc818c3934d2978ed Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:50:26 +0200 Subject: [PATCH 09/78] fix(quality): report the real failure line and stop double-counting ci.yml gates (#11321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on a 17-PR combined board: validate-release-green within the board's 287/287, typecheck:core clean. Two accuracy bugs in the release-green verdict tool: an unanchored regex blamed a passing test line (matching a filename containing 'fail'), and 6 gates were double-recorded as both hard-failure and drift due to an id-format mismatch (ci.yml script name vs curated id). Found while reading the #9985 verdict — good catch. --- scripts/quality/validate-release-green.mjs | 54 +++++++++- tests/unit/validate-release-green.test.ts | 116 +++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 725ac93d9f..264e1eac57 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -90,13 +90,30 @@ export function baselineValue(metric, root = ROOT) { } } +// A line that is unambiguously a PASS. Test reporters print the file name on BOTH the +// pass and the fail line, so a green line for a file whose NAME contains "fail" +// (fail-fast-*.test.ts, failover-*.test.ts) must never be offered as a failure cause. +const GREEN_LINE_RE = /^[✓✔√]/; + +// Markers that are only meaningful at the START of a line: "FAIL" also occurs inside test +// FILE NAMES and inside summary prose ("Test Files 1 failed"), so matching it anywhere — +// and case-insensitively — reports a PASSING file as the cause of the red. +const LINE_START_FAILURE_RE = /^(?:[✖✗×]|FAIL\b|not ok\b|REGRESS)/; + +// Markers that are unambiguous ANYWHERE in the line: tsc and Node emit them mid-line +// ("src/x.ts(10,5): error TS2322: ..."), so these stay unanchored. They are matched +// case-SENSITIVELY because that is how the emitting tools actually spell them. +const INLINE_FAILURE_RE = /\berror TS\d+\b|\bAssertionError\b|\bError:|\bREGRESS/; + /** Best-effort "first meaningful failure line" from captured command output. */ export function firstFailureLine(out) { const lines = String(out || "") .split("\n") .map((l) => l.trim()) .filter(Boolean); - const hit = lines.find((l) => /✖|✗|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l)); + const hit = lines.find( + (l) => !GREEN_LINE_RE.test(l) && (LINE_START_FAILURE_RE.test(l) || INLINE_FAILURE_RE.test(l)) + ); return (hit || lines[lines.length - 1] || "failed").slice(0, 200); } @@ -232,6 +249,36 @@ export function fullCiTimeoutFor(gateId) { return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS; } +// ci.yml gate scripts whose result the CURATED pass already records under a DIFFERENT id. +// Without this map the --full-ci pass re-records them unconditionally as kind:"hard" while +// the curated pass recorded them as kind:"drift", and the SAME gate is printed in BOTH +// verdict buckets of one report (file-size / compression-budget appeared as a hard failure +// and as drift simultaneously in the #9985 verdict). +export const FULL_CI_CURATED_ALIASES = { + lint: "lint-errors", + "check:workflows": "workflow-lint", + "check:complexity-ratchets": "complexity", +}; + +/** Curated-pass id equivalent to a ci.yml gate script id ("check:file-size" -> "file-size"). */ +export function curatedEquivalentId(scriptId) { + const id = String(scriptId || ""); + if (Object.hasOwn(FULL_CI_CURATED_ALIASES, id)) return FULL_CI_CURATED_ALIASES[id]; + return id.startsWith("check:") ? id.slice("check:".length) : id; +} + +/** + * Bucket a --full-ci gate must be reported under: the classification the curated pass already + * gave the equivalent gate, else "hard" (the --full-ci default for gates the curated list does + * not cover). This only changes WHICH BUCKET a result is printed in — it never changes whether + * a gate runs, nor whether it passed. + */ +export function fullCiKindFor(scriptId, results) { + const equivalent = curatedEquivalentId(scriptId); + const curated = (results || []).find((r) => r.id === scriptId || r.id === equivalent); + return curated?.kind ?? "hard"; +} + /** * Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run. * Each entry: { id, job, args:["run",