From 9a78ea2225ede859dd829989405c212de9983e49 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:36:37 -0300 Subject: [PATCH 01/83] fix(providers): stop marking a multi-quota-window provider exhausted when only some windows are depleted (LIMIT-200 snapshot eviction drops idle healthy windows) (#8431) (#8445) Co-authored-by: ikelvingo --- .../fixes/8431-multiwindow-quota-eviction.md | 1 + src/lib/db/quotaSnapshots.ts | 36 +++--- .../8431-multiwindow-quota-eviction.test.ts | 119 ++++++++++++++++++ 3 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fixes/8431-multiwindow-quota-eviction.md create mode 100644 tests/unit/8431-multiwindow-quota-eviction.test.ts diff --git a/changelog.d/fixes/8431-multiwindow-quota-eviction.md b/changelog.d/fixes/8431-multiwindow-quota-eviction.md new file mode 100644 index 0000000000..9db9be88f5 --- /dev/null +++ b/changelog.d/fixes/8431-multiwindow-quota-eviction.md @@ -0,0 +1 @@ +- fix(providers): stop marking a multi-quota-window provider exhausted when only some windows are depleted (LIMIT-200 snapshot eviction drops idle healthy windows) (#8431) diff --git a/src/lib/db/quotaSnapshots.ts b/src/lib/db/quotaSnapshots.ts index e8495365ae..1e77cc47fb 100644 --- a/src/lib/db/quotaSnapshots.ts +++ b/src/lib/db/quotaSnapshots.ts @@ -84,29 +84,37 @@ export function getQuotaSnapshots(opts: { } } +/** + * Returns the single latest snapshot row for each distinct `window_key` + * ever observed for this connection. + * + * Deliberately NOT a "most recent N rows across all windows" query: a + * connection with many quota windows where only a subset actively churn + * (frequent writes as they drain) and the rest stay idle/healthy (a single + * old row each, thanks to the #4438 no-op-write dedup) would otherwise have + * its recent-rows slice flooded entirely by the hot windows, silently + * evicting the idle windows from rehydration (#8431). Scoping "latest" PER + * window_key via a window function keeps every window visible regardless of + * how skewed the write frequency is across windows. + */ export function getLatestQuotaSnapshotsForConnection(connectionId: string): QuotaSnapshotRow[] { const db = getDbInstance() as unknown as DbLike; try { const rows = db .prepare( - `SELECT * FROM quota_snapshots - WHERE connection_id = ? - ORDER BY created_at DESC - LIMIT 200` + `SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY window_key ORDER BY created_at DESC, id DESC + ) AS rn + FROM quota_snapshots + WHERE connection_id = ? + ) + WHERE rn = 1` ) .all(connectionId); - const latestByWindow = new Map(); - for (const row of rows) { - const snapshot = rowToCamel(row) as unknown as QuotaSnapshotRow; - const windowKey = - (snapshot as unknown as { windowKey?: string }).windowKey ?? snapshot.window_key; - if (!windowKey || latestByWindow.has(windowKey)) continue; - latestByWindow.set(windowKey, snapshot); - } - - return [...latestByWindow.values()]; + return rows.map((row) => rowToCamel(row) as unknown as QuotaSnapshotRow); } catch (err: any) { if (err?.message?.includes("no such table")) { return []; diff --git a/tests/unit/8431-multiwindow-quota-eviction.test.ts b/tests/unit/8431-multiwindow-quota-eviction.test.ts new file mode 100644 index 0000000000..ebe0767a50 --- /dev/null +++ b/tests/unit/8431-multiwindow-quota-eviction.test.ts @@ -0,0 +1,119 @@ +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"; + +/** + * #8431 — a provider with many quota windows (e.g. codebuddy-cn: 1 Monthly + + * up to 8 Bonus Packs) can be wrongly reported as fully exhausted after a + * fresh boot (empty in-memory cache) even though most windows still have + * balance. + * + * Root cause: `getLatestQuotaSnapshotsForConnection()` fetched the most + * recent 200 rows for the connection (across ALL windows), then deduped by + * `window_key` *inside* that slice. A connection where a few windows churn + * frequently (draining, so they keep writing fresh rows) and the rest stay + * idle/healthy (a single old row each) can have its top-200 slice entirely + * flooded by the hot windows once they collectively accumulate >200 rows. + * The idle-but-healthy windows' only row falls outside the slice and is + * silently dropped from rehydration, so `isExhausted()` — which is correct + * on the data it's given — reports the connection exhausted because every + * window it was handed genuinely is at 0%. + * + * Regression guard: without the fix, only the 3 hot windows survive + * rehydration (of 9 total) and `isQuotaExhaustedForRequest` wrongly reports + * `true`. With the fix, all 9 windows survive and the request stays + * eligible because 6 of the 9 windows still have balance. + */ +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-8431-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const COLD_WINDOWS = ["Bonus Pack 1", "Bonus Pack 2", "Bonus Pack 3", "Bonus Pack 4", "Weekly", "Daily"]; +const HOT_WINDOWS = ["Monthly", "Bonus Pack 5", "Bonus Pack 6"]; + +test("#8431 idle healthy windows survive rehydration even when hot windows accumulate >200 rows", () => { + const connectionId = "conn-codebuddy-cn-8431"; + const provider = "codebuddy-cn"; + + // 6 cold, healthy windows — each written exactly once (mirrors the #4438 + // no-op-write dedup for windows whose value never changes) and BEFORE the + // hot rows below. + for (const windowKey of COLD_WINDOWS) { + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: windowKey, + remaining_percentage: 80, + is_exhausted: 0, + next_reset_at: "2099-01-01T00:00:00.000Z", + window_duration_ms: null, + raw_data: null, + }); + } + + // 3 hot, actively-draining windows — 70 iterations x 3 windows = 210 rows, + // all created after the cold rows, exceeding the old LIMIT 200. + for (let i = 0; i < 70; i++) { + for (const windowKey of HOT_WINDOWS) { + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: windowKey, + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: "2099-01-08T00:00:00.000Z", + window_duration_ms: null, + raw_data: null, + }); + } + } + + const rehydrated = quotaSnapshotsDb.getLatestQuotaSnapshotsForConnection(connectionId); + const rehydratedKeys = rehydrated + .map((s) => (s as unknown as { windowKey?: string }).windowKey ?? s.window_key) + .sort(); + + assert.equal( + rehydrated.length, + 9, + `expected all 9 windows to survive rehydration, got ${rehydrated.length}: ${rehydratedKeys.join(", ")}` + ); + + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, provider, "deepseek-v4-pro"), + false, + "6 of 9 windows still have balance — the connection must not be reported as exhausted" + ); +}); + +test("#8431 a single-window provider is still correctly reported exhausted", () => { + const connectionId = "conn-single-window-8431"; + const provider = "openai"; + + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: "weekly", + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: null, + window_duration_ms: null, + raw_data: null, + }); + + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, provider, "gpt-5"), + true, + "single depleted window must still correctly report exhaustion" + ); +}); From b64361dd2ccb9be3312d1aa4159622f026811905 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:36:44 -0300 Subject: [PATCH 02/83] fix(resilience): cap the connection cooldown after a 429 burst so combo fallback is not blacked out past the real rate-limit window (#8396) (#8446) Co-authored-by: ikelvingo --- changelog.d/fixes/8396-cooldown-429-cap.md | 1 + open-sse/services/accountFallback.ts | 8 +- .../services/accountFallback/cooldownCap.ts | 26 ++++++ tests/unit/8396-cooldown-429-cap.test.ts | 80 +++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/8396-cooldown-429-cap.md create mode 100644 open-sse/services/accountFallback/cooldownCap.ts create mode 100644 tests/unit/8396-cooldown-429-cap.test.ts diff --git a/changelog.d/fixes/8396-cooldown-429-cap.md b/changelog.d/fixes/8396-cooldown-429-cap.md new file mode 100644 index 0000000000..684e2e1f6f --- /dev/null +++ b/changelog.d/fixes/8396-cooldown-429-cap.md @@ -0,0 +1 @@ +- fix(resilience): cap the connection cooldown after a 429 burst so combo fallback is not blacked out past the real rate-limit window (#8396) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 28f871719f..c10ade929f 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -56,6 +56,7 @@ import { import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts"; import { evictLockoutOverflow } from "./accountFallback/lockoutEviction.ts"; export { MODEL_LOCKOUT_EVICTION_CAP } from "./accountFallback/lockoutEviction.ts"; +import { capScaledCooldownMs } from "./accountFallback/cooldownCap.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -1451,9 +1452,14 @@ export function checkFallbackError( typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0 ? profile.baseCooldownMs : COOLDOWN_MS.transientInitial; + // #8396: cap against profile.maxCooldownMs, mirroring the model-lockout path. return { baseCooldownMs, - cooldownMs: getScaledCooldown(baseCooldownMs, level + 1, maxBackoffSteps), + cooldownMs: capScaledCooldownMs( + getScaledCooldown(baseCooldownMs, level + 1, maxBackoffSteps), + profile?.maxCooldownMs, + BACKOFF_CONFIG.max + ), newBackoffLevel: Math.min(level + 1, maxBackoffSteps), }; } diff --git a/open-sse/services/accountFallback/cooldownCap.ts b/open-sse/services/accountFallback/cooldownCap.ts new file mode 100644 index 0000000000..66a50da03b --- /dev/null +++ b/open-sse/services/accountFallback/cooldownCap.ts @@ -0,0 +1,26 @@ +/** + * accountFallback/cooldownCap.ts — absolute ceiling for exponentially-scaled cooldowns. + * + * Extracted from services/accountFallback.ts (file-size gate): a pure, one-purpose clamp + * so the connection-level 429/retryable-error cooldown path (getScaledBaseCooldown, inside + * checkFallbackError) applies the same absolute ceiling the model-lockout path + * (recordModelLockoutFailure) already enforces. Fixes #8396 — after a sustained 429 burst + * pushed backoffLevel high, `baseCooldownMs * 2^level` had no upper bound on this path and + * could black a connection out for hours, long past any real rate-limit reset window. + */ + +/** + * Clamp an exponentially-scaled cooldown to `maxCooldownMs` (operator-configured, per + * ProviderProfile) falling back to `fallbackMaxMs` (e.g. BACKOFF_CONFIG.max) when the + * profile did not configure one. Never widens the cooldown, only bounds it — the + * exponential backoff itself is untouched. + */ +export function capScaledCooldownMs( + cooldownMs: number, + maxCooldownMs: number | undefined | null, + fallbackMaxMs: number +): number { + const cap = + typeof maxCooldownMs === "number" && maxCooldownMs > 0 ? maxCooldownMs : fallbackMaxMs; + return Math.min(cooldownMs, cap); +} diff --git a/tests/unit/8396-cooldown-429-cap.test.ts b/tests/unit/8396-cooldown-429-cap.test.ts new file mode 100644 index 0000000000..bbdc9a0157 --- /dev/null +++ b/tests/unit/8396-cooldown-429-cap.test.ts @@ -0,0 +1,80 @@ +// Regression guard for #8396: after a burst of retryable failures (e.g. 429s), the +// connection-level cooldown computed by checkFallbackError() must never exceed +// profile.maxCooldownMs. Before the fix, getScaledBaseCooldown() scaled +// baseCooldownMs * 2^backoffLevel with NO absolute ceiling on the connection-level +// path (unlike the model-lockout path, which already clamps to maxCooldownMs). A +// legacy-migrated OAuth profile with baseCooldownMs=60000 and maxBackoffSteps=8 +// produced cooldownMs = 60000 * 2^8 = 15,360,000ms (~4.27h), blowing straight past +// an operator-configured maxCooldownMs of 10 minutes. +import test from "node:test"; +import assert from "node:assert/strict"; +import { checkFallbackError, type ProviderProfile } from "../../open-sse/services/accountFallback.ts"; + +const legacyMigratedOAuthProfile: ProviderProfile = { + baseCooldownMs: 60000, + useUpstreamRetryHints: false, + maxCooldownMs: 600000, // operator-configured 10-minute ceiling + maxBackoffSteps: 8, + failureThreshold: 3, + resetTimeoutMs: 30 * 60 * 1000, + transientCooldown: 5000, + rateLimitCooldown: 60000, + maxBackoffLevel: 8, + circuitBreakerThreshold: 3, + circuitBreakerReset: 60000, + providerFailureThreshold: 3, + providerFailureWindowMs: 60000, + providerCooldownMs: 60000, +}; + +test("#8396: connection-level 429 cooldown after a high-failureIndex burst is capped at profile.maxCooldownMs", () => { + const result = checkFallbackError( + 429, + "", + 8, // backoffLevel — a large failureIndex from a sustained 429 burst + "some-model", + "test-oauth-provider", + null, + legacyMigratedOAuthProfile + ); + + assert.equal(result.shouldFallback, true); + assert.ok( + result.cooldownMs <= legacyMigratedOAuthProfile.maxCooldownMs, + `expected cooldownMs to be capped at ${legacyMigratedOAuthProfile.maxCooldownMs}ms ` + + `(profile.maxCooldownMs), but got ${result.cooldownMs}ms — no absolute ceiling was ` + + "applied on the connection-level 429 cooldown path" + ); +}); + +test("#8396: an upstream Retry-After hint is honored (bypasses the exponential scale entirely)", () => { + const apikeyProfile: ProviderProfile = { + ...legacyMigratedOAuthProfile, + useUpstreamRetryHints: true, + }; + const headers = new Headers({ "retry-after": "30" }); + + const result = checkFallbackError(429, "", 8, "some-model", "test-apikey-provider", headers, apikeyProfile); + + assert.equal(result.usedUpstreamRetryHint, true); + assert.ok( + result.cooldownMs <= 31000 && result.cooldownMs >= 29000, + `expected the ~30s upstream Retry-After hint to be honored, got ${result.cooldownMs}ms` + ); +}); + +test("#8396: a single 429 (low backoffLevel) still cools down normally, well under the cap", () => { + const result = checkFallbackError( + 429, + "", + 0, // first failure + "some-model", + "test-oauth-provider", + null, + legacyMigratedOAuthProfile + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, legacyMigratedOAuthProfile.baseCooldownMs); + assert.ok(result.cooldownMs < legacyMigratedOAuthProfile.maxCooldownMs); +}); From d7f947586430738d6393a64e733f876456bf0783 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:36:52 -0300 Subject: [PATCH 03/83] fix(backend): make disabling the global per-key proxy toggle override existing per-key proxy assignments (#8385) (#8447) Co-authored-by: ikelvingo --- .../fixes/8385-perkey-proxy-global-toggle.md | 1 + src/lib/db/settings.ts | 6 +- .../8385-perkey-proxy-global-toggle.test.ts | 117 ++++++++++++++++++ tests/unit/proxy-registry.test.ts | 10 +- tests/unit/resolve-proxy-family.test.ts | 6 +- 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/8385-perkey-proxy-global-toggle.md create mode 100644 tests/unit/8385-perkey-proxy-global-toggle.test.ts diff --git a/changelog.d/fixes/8385-perkey-proxy-global-toggle.md b/changelog.d/fixes/8385-perkey-proxy-global-toggle.md new file mode 100644 index 0000000000..f30d5a44ed --- /dev/null +++ b/changelog.d/fixes/8385-perkey-proxy-global-toggle.md @@ -0,0 +1 @@ +- fix(backend): make disabling the global per-key proxy toggle override existing per-key proxy assignments (#8385) diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 5c997e4588..647a318013 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -523,8 +523,10 @@ export async function resolveProxyForConnection( // Step 2: API key-level proxy (only if per-key proxy is enabled globally or per-connection) if (apiKeyId) { - // Check if per-key proxy is allowed: globally OR per-connection - const perKeyEnabled = globalPerKeyProxyEnabled || connectionPerKeyProxyEnabled; + // Check if per-key proxy is allowed: the global toggle is a true override — + // when it is off, no connection's per-key assignment may apply, regardless + // of that connection's own per_key_proxy_enabled flag (#8385). + const perKeyEnabled = globalPerKeyProxyEnabled && connectionPerKeyProxyEnabled; if (perKeyEnabled) { try { diff --git a/tests/unit/8385-perkey-proxy-global-toggle.test.ts b/tests/unit/8385-perkey-proxy-global-toggle.test.ts new file mode 100644 index 0000000000..84a0226ca8 --- /dev/null +++ b/tests/unit/8385-perkey-proxy-global-toggle.test.ts @@ -0,0 +1,117 @@ +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-8385-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +interface ConnectionRef { + id: string; +} + +interface ProxyResolution { + level: string | null; + proxy: unknown; +} + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("issue #8385: global perKeyProxyEnabled=false must override a connection's per_key_proxy_enabled=1", async () => { + await resetStorage(); + + core + .getDbInstance() + .prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'false')" + ) + .run(); + + const conn = (await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "conn-8385", + apiKey: "sk-8385", + })) as unknown as ConnectionRef; + await providersDb.updateProviderConnection(conn.id, { perKeyProxyEnabled: true }); + + const proxy = await proxiesDb.createProxy({ + name: "Per-Key Proxy 8385", + type: "http", + host: "perkey.8385.local", + port: 8080, + }); + + const key = await apiKeysDb.createApiKey("probe-8385-key", "machine-8385"); + await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); + + const resolved = (await settingsDb.resolveProxyForConnection( + conn.id, + key.id + )) as unknown as ProxyResolution; + + assert.notEqual( + resolved?.level, + "apiKey", + `expected global-off to override per-key assignment, but got level=${resolved?.level} proxy=${JSON.stringify(resolved?.proxy)}` + ); +}); + +test("issue #8385: global perKeyProxyEnabled=true still allows the per-key assignment to apply", async () => { + await resetStorage(); + + core + .getDbInstance() + .prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" + ) + .run(); + + const conn = (await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "conn-8385-on", + apiKey: "sk-8385-on", + })) as unknown as ConnectionRef; + await providersDb.updateProviderConnection(conn.id, { perKeyProxyEnabled: true }); + + const proxy = await proxiesDb.createProxy({ + name: "Per-Key Proxy 8385 On", + type: "http", + host: "perkey-on.8385.local", + port: 8081, + }); + + const key = await apiKeysDb.createApiKey("probe-8385-key-on", "machine-8385-on"); + await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); + + const resolved = (await settingsDb.resolveProxyForConnection( + conn.id, + key.id + )) as unknown as ProxyResolution; + + assert.equal( + resolved?.level, + "apiKey", + `expected global-on to allow the per-key assignment, but got level=${resolved?.level}` + ); +}); diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 3c3812feb3..35e1c63940 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -304,6 +304,7 @@ test("resolveProxyForConnection uses apiKey proxy before account-level proxy", a name: "api-key-proxy", apiKey: "sk-apikey-proxy", }); + const connId = (conn as any).id; const accountProxy = await proxiesDb.createProxy({ name: "Account Proxy", @@ -311,17 +312,20 @@ test("resolveProxyForConnection uses apiKey proxy before account-level proxy", a host: "account.local", port: 8081, }); - await proxiesDb.assignProxyToScope("account", (conn as any).id, accountProxy.id); + await proxiesDb.assignProxyToScope("account", connId, accountProxy.id); const key = await apiKeysDb.createApiKey("proxy-test-key", "machine-p1"); - // Enable per-key proxy globally so the API key's proxy_id is honored + // Enable per-key proxy globally (master gate) and on the connection itself + // (#8385: the global toggle is a true AND-override, not an independent + // opt-in path — both must be on for the api-key-level proxy to apply). core .getDbInstance() .prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" ) .run(); + await providersDb.updateProviderConnection(connId, { perKeyProxyEnabled: true }); const apiKeyProxy = await proxiesDb.createProxy({ name: "API Key Proxy", @@ -331,7 +335,7 @@ test("resolveProxyForConnection uses apiKey proxy before account-level proxy", a }); await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: apiKeyProxy.id }); - const resolved = await settingsDb.resolveProxyForConnection((conn as any).id, key.id); + const resolved = await settingsDb.resolveProxyForConnection(connId, key.id); assert.ok(resolved); assert.equal((resolved as any).level, "apiKey"); assert.equal((resolved as any).proxy.host, "apikey.local"); diff --git a/tests/unit/resolve-proxy-family.test.ts b/tests/unit/resolve-proxy-family.test.ts index 803aee5bb0..6e12f618ad 100644 --- a/tests/unit/resolve-proxy-family.test.ts +++ b/tests/unit/resolve-proxy-family.test.ts @@ -115,12 +115,16 @@ test("api-key-level proxy carries family=ipv6 (Step 2 object literal)", async () name: "key-ipv6", apiKey: "sk-key-ipv6", }); + const connId = (conn as any).id; core .getDbInstance() .prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" ) .run(); + // #8385: the global toggle is a true AND-override — the connection's own + // per_key_proxy_enabled must also be on for the api-key-level proxy to apply. + await providersDb.updateProviderConnection(connId, { perKeyProxyEnabled: true }); const proxy = await proxiesDb.createProxy({ name: "IPv6 API Key Proxy", type: "https", @@ -131,7 +135,7 @@ test("api-key-level proxy carries family=ipv6 (Step 2 object literal)", async () const key = await apiKeysDb.createApiKey("family-key", "machine-f1"); await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); - const resolved = await settingsDb.resolveProxyForConnection((conn as any).id, key.id); + const resolved = await settingsDb.resolveProxyForConnection(connId, key.id); assert.ok(resolved); assert.equal((resolved as any).level, "apiKey"); assert.equal((resolved as any).proxy.family, "ipv6"); From 312e24e785fd6f99298cb594c7caeffc9fd37a56 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:36:59 -0300 Subject: [PATCH 04/83] fix(plugins): fire registered+active plugin hooks (onRequest/onResponse/onError) during proxying instead of never invoking them (#8395) (#8449) Co-authored-by: ikelvingo --- changelog.d/fixes/8395-plugin-hooks-fire.md | 1 + open-sse/handlers/chatCore.ts | 7 + src/lib/plugins/loader.ts | 54 ++++++- tests/unit/8395-plugin-hooks-fire.test.ts | 155 ++++++++++++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/8395-plugin-hooks-fire.md create mode 100644 tests/unit/8395-plugin-hooks-fire.test.ts diff --git a/changelog.d/fixes/8395-plugin-hooks-fire.md b/changelog.d/fixes/8395-plugin-hooks-fire.md new file mode 100644 index 0000000000..4465f33254 --- /dev/null +++ b/changelog.d/fixes/8395-plugin-hooks-fire.md @@ -0,0 +1 @@ +- fix(plugins): fire registered+active plugin hooks (onRequest/onResponse/onError) during proxying instead of never invoking them (#8395) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c1231c79f6..5d7f3d5dbc 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4477,6 +4477,13 @@ export async function handleChatCore({ if (typeof model === "string" && model) echoModelInObject(translatedResponse, model); // #1311: echo the requested alias/combo name in the non-streaming response model. if (echoModel) echoModelInObject(translatedResponse, echoModel); + + // ── Plugin onResponse hook (fire-and-forget) ── + // #8395: the streaming branch below already calls this; the non-streaming + // (stream:false) branch returned without it, so onResponse never fired for + // non-streaming requests at all. + await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo }); + return { success: true, response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders), diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts index e49142bffe..71292ec237 100644 --- a/src/lib/plugins/loader.ts +++ b/src/lib/plugins/loader.ts @@ -22,6 +22,13 @@ const log = logger("PLUGIN_LOADER"); const DEFAULT_HOOK_TIMEOUT = 10_000; const SIGKILL_GRACE_MS = 3_000; +// #8395: stdout/stderr forwarding hygiene — cap how much of a plugin's own console +// output we relay per stream, so a runaway/misbehaving plugin can't flood memory or +// the log sink. Mirrors the per-plugin rate-limit hygiene already used for hooks +// (hooks.ts::isRateLimited). +const MAX_FORWARDED_LINES_PER_STREAM = 500; +const MAX_FORWARDED_LINE_LENGTH = 4_000; + /** * Compute a `sha256-` integrity hash of the given source string. * Matches the SRI (Subresource Integrity) format: `sha256-`. @@ -38,6 +45,44 @@ export interface LoadedPlugin { cleanup: () => void; } +/** + * #8395: forward a plugin child process's stdout/stderr to the parent's structured + * logger, line-buffered. Without this, plugin console.log/console.error output is + * silently discarded at the OS level (the child is spawned with that stream set to + * "ignore"), even though the plugin's hook handlers do run correctly over IPC. + * Caps total forwarded lines per stream to avoid a runaway plugin flooding the log. + */ +function forwardChildOutput( + stream: NodeJS.ReadableStream | null, + pluginName: string, + level: "info" | "error" +): void { + if (!stream) return; + + let buffer = ""; + let forwardedLines = 0; + + stream.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf-8"); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.length > 0 && forwardedLines < MAX_FORWARDED_LINES_PER_STREAM) { + forwardedLines++; + const truncated = + line.length > MAX_FORWARDED_LINE_LENGTH + ? `${line.slice(0, MAX_FORWARDED_LINE_LENGTH)}…` + : line; + log[level]("plugin.output", { name: pluginName, line: truncated }); + } + + newlineIndex = buffer.indexOf("\n"); + } + }); +} + // ── Plugin host script (runs in child process over IPC) ── // Uses process.send()/process.on("message") — NOT worker_threads. // Written as .mjs to force ESM execution regardless of package.json. @@ -139,9 +184,16 @@ export async function loadPlugin( const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], { windowsHide: true, env, - stdio: ["ignore", "ignore", "ignore", "ipc"], + // #8395: stdout/stderr must be piped (not "ignore") so the plugin's own + // console.log/console.error output — the SDK's documented logging pattern + // (sdk.ts) — is observable on the parent side instead of discarded at the OS + // level. See forwardChildOutput() below. + stdio: ["ignore", "pipe", "pipe", "ipc"], }); + forwardChildOutput(child.stdout, manifest.name, "info"); + forwardChildOutput(child.stderr, manifest.name, "error"); + // Track pending calls with timeout support const pendingCalls: Map< string, diff --git a/tests/unit/8395-plugin-hooks-fire.test.ts b/tests/unit/8395-plugin-hooks-fire.test.ts new file mode 100644 index 0000000000..205134f60d --- /dev/null +++ b/tests/unit/8395-plugin-hooks-fire.test.ts @@ -0,0 +1,155 @@ +// Regression test for #8395 — "registered+active plugin hooks never fire during +// proxying". The IPC dispatch itself works (manager.ts registers loader.ts's real +// callHook-backed callables and emitHookBlocking does invoke them), but +// loader.ts::loadPlugin() spawns the plugin host with +// `stdio: ["ignore", "ignore", "ignore", "ipc"]` — stdout/stderr are discarded at the +// OS level, so a plugin following the SDK's own documented console.log pattern +// produces zero observable output. This test proves the hook body DOES execute and +// its return value DOES come back (disproving the "hooks never fire" framing), while +// pinning the real, narrower bug: the plugin's own stdout/stderr must be observable +// on the parent side after a hook call. +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadPlugin, type LoadedPlugin } from "../../src/lib/plugins/loader.ts"; + +test( + "loadPlugin forwards the plugin child process's stdout to an observable channel", + { timeout: 10_000 }, + async (t) => { + const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-8395-")); + const entryPoint = join(pluginDir, "index.mjs"); + let loaded: LoadedPlugin | undefined; + + t.after(async () => { + loaded?.cleanup(); + await rm(pluginDir, { recursive: true, force: true }); + }); + + await writeFile( + entryPoint, + ` +export async function onRequest(ctx) { + console.log("PLUGIN_FIRED_MARKER_8395", ctx.requestId); + return { + metadata: { pluginSawRequestId: ctx.requestId }, + }; +} +`, + "utf-8" + ); + + loaded = await loadPlugin(entryPoint, { + name: "stdout-forward-test", + version: "1.0.0", + license: "MIT", + main: "index.mjs", + source: "local", + tags: [], + requires: { permissions: [] }, + hooks: { onRequest: true, onResponse: false, onError: false }, + skills: [], + enabledByDefault: false, + configSchema: {}, + }); + + // Capture everything written to the parent process's stdout while the hook runs. + const originalWrite = process.stdout.write.bind(process.stdout); + let captured = ""; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + captured += typeof chunk === "string" ? chunk : String(chunk); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (originalWrite as any)(chunk, ...rest); + }) as typeof process.stdout.write; + + let result: unknown; + try { + result = await loaded.plugin.onRequest?.({ + requestId: "req-8395-marker", + body: { model: "gpt-4" }, + model: "gpt-4", + metadata: {}, + }); + + // Give the async stdout "data" event a tick to arrive after the IPC "result" + // message (they race over two independent channels of the same child process). + await new Promise((resolve) => setTimeout(resolve, 300)); + } finally { + process.stdout.write = originalWrite; + } + + // 1) The IPC round trip itself works: the hook body ran and its return value + // came back correctly. This disproves the "hook dispatch is broken" theory. + assert.deepEqual(result, { + metadata: { pluginSawRequestId: "req-8395-marker" }, + }); + + // 2) The actual #8395 symptom: the plugin's own console.log output must be + // observable on the parent side (forwarded from the child's stdout), not + // silently discarded by `stdio: ["ignore", "ignore", "ignore", "ipc"]`. + assert.ok( + captured.includes("PLUGIN_FIRED_MARKER_8395") && captured.includes("req-8395-marker"), + `expected the plugin's own stdout output to be forwarded/logged somewhere ` + + `observable on the parent side; captured=${JSON.stringify(captured)}` + ); + } +); + +test( + "loadPlugin no longer spawns the plugin host with stdout/stderr fully ignored", + async () => { + const source = await readFile( + join(import.meta.dirname, "../../src/lib/plugins/loader.ts"), + "utf-8" + ); + // The original bug: stdio: ["ignore", "ignore", "ignore", "ipc"] discards + // stdout (fd 1) and stderr (fd 2) at the OS level unconditionally. + assert.doesNotMatch( + source, + /stdio:\s*\[\s*["']ignore["']\s*,\s*["']ignore["']\s*,\s*["']ignore["']\s*,\s*["']ipc["']\s*\]/, + "loader.ts must not spawn the plugin host with stdout+stderr both set to " + + "'ignore' — that silently discards all plugin console.log/console.error output" + ); + } +); + +// Secondary #8395 finding: runPluginOnResponseHook was only wired into chatCore.ts's +// STREAMING success path — the non-streaming (stream:false) JSON-return branch +// returned without ever calling it, so onResponse never fired for stream:false +// requests at all. chatCore.ts is a very large, heavily-mocked-provider-dependent +// handler (4800+ lines) — a full handleChatCore() integration harness for this one +// call site would dwarf the fix. Instead, structurally pin that both success +// branches call the hook exactly once, complementing the existing behavioral +// contract test for runPluginOnResponseHook itself +// (tests/unit/chatcore-plugin-onresponse.test.ts). +test("chatCore.ts calls runPluginOnResponseHook from both the non-streaming and streaming success paths", async () => { + const source = await readFile( + join(import.meta.dirname, "../../open-sse/handlers/chatCore.ts"), + "utf-8" + ); + + const nonStreamingReturnIndex = source.indexOf("buildNonStreamingJsonResponse(translatedResponse"); + const hookCallIndex = source.indexOf( + "await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo });" + ); + const secondHookCallIndex = source.indexOf( + "await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo });", + hookCallIndex + 1 + ); + + assert.notEqual(hookCallIndex, -1, "expected at least one runPluginOnResponseHook call site"); + assert.notEqual( + secondHookCallIndex, + -1, + "expected TWO runPluginOnResponseHook call sites — one per success branch " + + "(non-streaming JSON return and streaming SSE return)" + ); + assert.ok( + hookCallIndex < nonStreamingReturnIndex, + "the non-streaming branch must call runPluginOnResponseHook BEFORE returning " + + "buildNonStreamingJsonResponse(...), not skip it" + ); +}); From 544ae2d3da97d3a3dba74f1ea0455165cb32fa59 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:37:12 -0300 Subject: [PATCH 05/83] fix(api): accept a missing status query-param on GET /api/plugins instead of rejecting null with Invalid status value (#8374) (#8399) --- .../fixes/8374-plugins-status-optional.md | 1 + src/app/api/plugins/route.ts | 2 +- .../unit/8374-plugins-status-optional.test.ts | 84 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/8374-plugins-status-optional.md create mode 100644 tests/unit/8374-plugins-status-optional.test.ts diff --git a/changelog.d/fixes/8374-plugins-status-optional.md b/changelog.d/fixes/8374-plugins-status-optional.md new file mode 100644 index 0000000000..2af60df3a0 --- /dev/null +++ b/changelog.d/fixes/8374-plugins-status-optional.md @@ -0,0 +1 @@ +- fix(api): accept a missing status query-param on GET /api/plugins instead of rejecting null with Invalid status value (#8374) diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts index c9b54645f8..22f742bed3 100644 --- a/src/app/api/plugins/route.ts +++ b/src/app/api/plugins/route.ts @@ -19,7 +19,7 @@ export async function GET(request: NextRequest) { const authError = await requireManagementAuth(request); if (authError) return authError; const url = new URL(request.url); - const statusResult = StatusSchema.safeParse(url.searchParams.get("status")); + const statusResult = StatusSchema.safeParse(url.searchParams.get("status") ?? undefined); if (!statusResult.success) { return NextResponse.json( { error: "Invalid status value", details: statusResult.error.issues }, diff --git a/tests/unit/8374-plugins-status-optional.test.ts b/tests/unit/8374-plugins-status-optional.test.ts new file mode 100644 index 0000000000..f5936b7e87 --- /dev/null +++ b/tests/unit/8374-plugins-status-optional.test.ts @@ -0,0 +1,84 @@ +/** + * Regression test for #8374 — GET /api/plugins returns "Invalid status value" + * when no `?status=` filter is passed. + * + * Root cause: `URLSearchParams.get("status")` returns `null` (not `undefined`) + * when the query param is absent. `z.enum([...]).optional()` widens the schema + * to accept `undefined`, but NOT `null` — so `safeParse(null)` fails and the + * route returns HTTP 400, even though the caller passed no filter at all. + * + * Fix: coerce `null` -> `undefined` before handing it to Zod, matching the + * repo's own established idiom (see registered-keys/route.ts, + * suggested-models/route.ts, quota/preview/route.ts). + */ + +import { test, before, 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"; +import { NextRequest } from "next/server"; + +// Hermetic DB: isolate from the shared dev DATA_DIR so this test never +// touches or depends on real plugin rows, and so a fresh install has no +// configured password (isAuthRequired() -> false -> GET reachable directly). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-8374-")); +const originalDataDir = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const pluginsDb = await import("../../src/lib/db/plugins.ts"); +const { GET } = await import("../../src/app/api/plugins/route.ts"); + +before(() => { + pluginsDb.insertPlugin({ + id: "test-plugin-8374", + name: "test-plugin-8374", + version: "1.0.0", + main: "index.js", + manifest: {}, + status: "active", + pluginDir: "/tmp/test-plugin-8374", + }); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +test("BUG #8374: GET /api/plugins with no ?status= returns 200, not 400", async () => { + // @ts-ignore - handler accepts NextRequest at runtime + const req = new NextRequest("http://localhost:3000/api/plugins"); + const res = await GET(req); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + assert.ok(Array.isArray(body.plugins), "response body must contain a plugins array"); +}); + +test("GET /api/plugins with a valid ?status= filter still works and filters", async () => { + // @ts-ignore + const req = new NextRequest("http://localhost:3000/api/plugins?status=active"); + const res = await GET(req); + const body = await res.json(); + assert.equal(res.status, 200); + assert.ok( + body.plugins.every((p: { status: string }) => p.status === "active"), + "all returned plugins must have the requested status" + ); + assert.ok( + body.plugins.some((p: { id: string }) => p.id === "test-plugin-8374"), + "the seeded active plugin must be present in the filtered result" + ); +}); + +test("GET /api/plugins with an invalid ?status= still returns 400", async () => { + // @ts-ignore + const req = new NextRequest("http://localhost:3000/api/plugins?status=bogus"); + const res = await GET(req); + const body = await res.json(); + assert.equal(res.status, 400); + assert.equal(body.error, "Invalid status value"); +}); From 73762b1b32ff4b96637e1719d8e24c53a2dc6581 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:37:18 -0300 Subject: [PATCH 06/83] fix(backend): stop prompt-cache affinity from silently reordering an explicit priority combo across models (#8370) (#8400) --- .../fixes/8370-priority-affinity-reorder.md | 1 + open-sse/services/combo.ts | 6 +- .../services/combo/promptCacheAffinity.ts | 28 +++ .../8370-priority-affinity-reorder.test.ts | 178 ++++++++++++++++++ 4 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/8370-priority-affinity-reorder.md create mode 100644 tests/unit/8370-priority-affinity-reorder.test.ts diff --git a/changelog.d/fixes/8370-priority-affinity-reorder.md b/changelog.d/fixes/8370-priority-affinity-reorder.md new file mode 100644 index 0000000000..851e000a82 --- /dev/null +++ b/changelog.d/fixes/8370-priority-affinity-reorder.md @@ -0,0 +1 @@ +- fix(backend): stop prompt-cache affinity from silently reordering an explicit priority combo across models (#8370) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ed1a3aece5..5441ebf8f5 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -90,6 +90,7 @@ import { expandPromptCacheAffinityTargets, expandPromptCacheAffinityTargetsFromConnections, resolvePromptCacheAffinityKey, + shouldProtectOriginalFirst, } from "./combo/promptCacheAffinity.ts"; import type { CompressionMode } from "./compression/types.ts"; import { getCachedProviderConnections } from "../../src/lib/db/readCache"; @@ -1390,10 +1391,7 @@ export async function handleComboChat({ ); if (promptCacheAffinity.applied) { const protectedOriginal = - (_sticky.stuck || - autoUsedExplicitRouter || - strategy === "quota-share" || - strategy === "weighted") && + shouldProtectOriginalFirst(_sticky.stuck, autoUsedExplicitRouter, strategy) && orderedTargets[0]; const protectedFirst = protectedOriginal ? (promptCacheAffinity.targets.find( diff --git a/open-sse/services/combo/promptCacheAffinity.ts b/open-sse/services/combo/promptCacheAffinity.ts index fdfe9e1352..070e50f84e 100644 --- a/open-sse/services/combo/promptCacheAffinity.ts +++ b/open-sse/services/combo/promptCacheAffinity.ts @@ -237,6 +237,34 @@ export function expandPromptCacheAffinityTargetsFromConnections( return expandedTargets; } +/** + * #8370: decide whether the strategy-selected first target must be re-pinned + * ahead of a global, cross-model prompt-cache-affinity reorder. + * + * `priority`, `fill-first`, and `lkgp` are deterministic, operator-ordered + * strategies: their first target is a meaningful choice (explicit priority + * order, first-available slot, last-known-good pin) that a rendezvous-hash + * reorder must not silently override, even though affinity is still free to + * pick among the remaining/fallback targets. `quota-share` and `weighted` + * were already protected before this fix; session stickiness and an explicit + * auto-router pin remain independently protected via their own flags. + */ +export function shouldProtectOriginalFirst( + stickyStuck: boolean, + autoUsedExplicitRouter: boolean, + strategy: string +): boolean { + return ( + stickyStuck || + autoUsedExplicitRouter || + strategy === "quota-share" || + strategy === "weighted" || + strategy === "priority" || + strategy === "fill-first" || + strategy === "lkgp" + ); +} + /** * Order eligible targets using rendezvous hashing. The original order is used * as the final tie-breaker, so targets sharing one account identity remain diff --git a/tests/unit/8370-priority-affinity-reorder.test.ts b/tests/unit/8370-priority-affinity-reorder.test.ts new file mode 100644 index 0000000000..760cd77071 --- /dev/null +++ b/tests/unit/8370-priority-affinity-reorder.test.ts @@ -0,0 +1,178 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + applyPromptCacheAffinity, + expandPromptCacheAffinityTargetsFromConnections, + shouldProtectOriginalFirst, +} from "../../open-sse/services/combo/promptCacheAffinity.ts"; +import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts"; + +// #8370: a `priority` combo declares an explicit, operator-chosen model order. +// Cross-model prompt-cache affinity (a global rendezvous-hash sort over the +// fully expanded, model-blind target list) was silently reordering that +// declaration, letting a lower-priority model's single account jump ahead of +// every account belonging to the highest-priority model. This file is the +// permanent regression guard for that fix (`shouldProtectOriginalFirst` in +// `open-sse/services/combo/promptCacheAffinity.ts`, wired into +// `open-sse/services/combo.ts`'s `protectedOriginal` gate). + +function modelTarget( + stepId: string, + modelStr: string, + provider: string, + allowedConnectionIds?: string[] +): ResolvedComboTarget { + return { + kind: "model", + stepId, + executionKey: stepId, + modelStr, + provider, + providerId: null, + connectionId: null, + weight: 1, + label: null, + ...(allowedConnectionIds ? { allowedConnectionIds } : {}), + } as ResolvedComboTarget; +} + +// Mirrors combo.ts's exact application of the fix: expand model-level targets +// to concrete accounts, run affinity, then re-pin the strategy's declared +// first target ahead of the affinity-sorted list when the strategy warrants it. +function applyComboLikeAffinityPin( + orderedTargets: ResolvedComboTarget[], + connectionsByProvider: Map>>, + body: Record, + strategy: string +): ResolvedComboTarget[] { + const expanded = expandPromptCacheAffinityTargetsFromConnections( + orderedTargets, + connectionsByProvider + ); + const affinity = applyPromptCacheAffinity(expanded, body, true); + if (!affinity.applied) return affinity.targets; + + const protectedOriginal = shouldProtectOriginalFirst(false, false, strategy) && orderedTargets[0]; + + const protectedFirst = protectedOriginal + ? (affinity.targets.find( + (target) => + target === protectedOriginal || + target.executionKey === protectedOriginal.executionKey || + target.executionKey.startsWith(`${protectedOriginal.executionKey}@`) + ) ?? protectedOriginal) + : null; + + return protectedFirst + ? [protectedFirst, ...affinity.targets.filter((target) => target !== protectedFirst)] + : affinity.targets; +} + +function buildCrossModelScenario() { + // Model A (priority 1) has 5 accounts; models B and C (priority 2/3) have 1 each — + // mirrors the issue's reported 3-models-expanded-to-N-accounts shape. + const orderedTargets = [ + modelTarget("step-a", "antigravity/gemini-3-pro", "antigravity"), + modelTarget("step-b", "ollamacloud/minimax-m3", "ollamacloud"), + modelTarget("step-c", "oc/deepseek-v4", "oc"), + ]; + const connectionsByProvider = new Map>>([ + [ + "antigravity", + [ + { id: "antigravity-acct-1" }, + { id: "antigravity-acct-2" }, + { id: "antigravity-acct-3" }, + { id: "antigravity-acct-4" }, + { id: "antigravity-acct-5" }, + ], + ], + ["ollamacloud", [{ id: "minimax-acct-1" }]], + ["oc", [{ id: "deepseek-acct-1" }]], + ]); + return { orderedTargets, connectionsByProvider }; +} + +// Brute-force a prompt_cache_key whose rendezvous winner is NOT one of model +// A's accounts, so the bug (if unfixed) is guaranteed to reproduce rather +// than passing by chance of the hash landing on model A anyway. +function findKeyThatWinsOutsideModelA( + connectionsByProvider: Map>>, + orderedTargets: ResolvedComboTarget[] +): string { + const expanded = expandPromptCacheAffinityTargetsFromConnections( + orderedTargets, + connectionsByProvider + ); + for (let i = 0; i < 500; i++) { + const key = `probe-key-${i}`; + const ranked = applyPromptCacheAffinity(expanded, { prompt_cache_key: key }, true); + if (ranked.targets[0]?.provider !== "antigravity") return key; + } + throw new Error("could not find a probe key whose rendezvous winner is outside model A"); +} + +test("BUG #8370: priority combo keeps its declared model-1-first order despite cross-model affinity", () => { + const { orderedTargets, connectionsByProvider } = buildCrossModelScenario(); + const key = findKeyThatWinsOutsideModelA(connectionsByProvider, orderedTargets); + const body = { prompt_cache_key: key }; + + // Sanity: without the fix's protection, raw affinity really does let a + // model B/C account win the global sort (proves the scenario reproduces). + const expanded = expandPromptCacheAffinityTargetsFromConnections( + orderedTargets, + connectionsByProvider + ); + const rawAffinity = applyPromptCacheAffinity(expanded, body, true); + assert.notEqual( + rawAffinity.targets[0]?.provider, + "antigravity", + "test setup invariant: raw affinity must pick outside model A for this key" + ); + + const result = applyComboLikeAffinityPin(orderedTargets, connectionsByProvider, body, "priority"); + + assert.equal( + result[0]?.provider, + "antigravity", + "priority combo must keep its declared highest-priority model first, not the rendezvous winner" + ); +}); + +test("shouldProtectOriginalFirst covers priority, fill-first, and lkgp", () => { + for (const strategy of ["priority", "fill-first", "lkgp"]) { + assert.equal( + shouldProtectOriginalFirst(false, false, strategy), + true, + `expected ${strategy} to be protected` + ); + } +}); + +test("shouldProtectOriginalFirst still covers the pre-existing quota-share/weighted/sticky/auto-router cases", () => { + assert.equal(shouldProtectOriginalFirst(false, false, "quota-share"), true); + assert.equal(shouldProtectOriginalFirst(false, false, "weighted"), true); + assert.equal(shouldProtectOriginalFirst(true, false, "round-robin"), true); + assert.equal(shouldProtectOriginalFirst(false, true, "round-robin"), true); +}); + +test("round-robin combo is NOT protected — it still gets full cross-model affinity reordering", () => { + const { orderedTargets, connectionsByProvider } = buildCrossModelScenario(); + const key = findKeyThatWinsOutsideModelA(connectionsByProvider, orderedTargets); + const body = { prompt_cache_key: key }; + + assert.equal(shouldProtectOriginalFirst(false, false, "round-robin"), false); + + const result = applyComboLikeAffinityPin( + orderedTargets, + connectionsByProvider, + body, + "round-robin" + ); + + assert.notEqual( + result[0]?.provider, + "antigravity", + "round-robin combo must still let prompt-cache affinity pick the winning account across models" + ); +}); From 1f58a29e9c3880ecfbaf056098a7dae0ebb130b9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:37:25 -0300 Subject: [PATCH 07/83] fix(api): estimate inline base64 image tokens instead of counting the data URL as text so it does not falsely exceed the context window (#8368) (#8401) --- changelog.d/fixes/8368-image-token-context.md | 1 + open-sse/services/contextManager.ts | 127 +++++++++++++++++- tests/unit/8368-image-token-context.test.ts | 120 +++++++++++++++++ 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/8368-image-token-context.md create mode 100644 tests/unit/8368-image-token-context.test.ts diff --git a/changelog.d/fixes/8368-image-token-context.md b/changelog.d/fixes/8368-image-token-context.md new file mode 100644 index 0000000000..b4641899da --- /dev/null +++ b/changelog.d/fixes/8368-image-token-context.md @@ -0,0 +1 @@ +- fix(api): estimate inline base64 image tokens instead of counting the data URL as text so it does not falsely exceed the context window (#8368) diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index bbe844a3ac..03d7cd6014 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -47,13 +47,134 @@ function getReserveTokensOverride(): number | null { // Rough chars-per-token ratio for quick estimation const CHARS_PER_TOKEN = 4; +// Bounded per-image token budget used in place of measuring the raw base64 +// payload as text. In line with the owner's PoC (~1052 total for prompt + +// 1 image) and litellm's calculate_img_tokens() default-count fast-path — +// see #8368 research notes. +const IMAGE_TOKEN_ESTIMATE = 1200; + +// Matches inline base64 data URLs, e.g. "data:image/png;base64,AAAA...". +// Deliberately scoped to `data:image/...;base64,` so remote (http/https) +// URLs and generic long base64 text strings stay on the text-estimation path. +const INLINE_BASE64_IMAGE_RE = /^data:image\/[a-zA-Z0-9.+-]+;base64,/; + +function isInlineBase64ImageUrl(value: unknown): boolean { + return typeof value === "string" && INLINE_BASE64_IMAGE_RE.test(value); +} + +// OpenAI chat.completions: { type: 'image_url', image_url: { url: 'data:...' } | 'data:...' } +function matchesOpenAIImageUrlShape(node: Record): boolean { + const imageUrl = node.image_url; + if (isInlineBase64ImageUrl(imageUrl)) return true; + return ( + !!imageUrl && + typeof imageUrl === "object" && + isInlineBase64ImageUrl((imageUrl as Record).url) + ); +} + +// AI SDK: { type: 'image', image: 'data:...' } (also covers Responses API's +// { type: 'input_image', image_url: 'data:...' } via matchesOpenAIImageUrlShape above). +function matchesAiSdkImageShape(node: Record): boolean { + return node.type === "image" && isInlineBase64ImageUrl(node.image); +} + +// Claude: { type: 'image', source: { type: 'base64', data: '...' } } +function matchesClaudeSourceShape(node: Record): boolean { + if (node.type !== "image") return false; + const source = node.source; + if (!source || typeof source !== "object") return false; + const src = source as Record; + return src.type === "base64" && typeof src.data === "string"; +} + +// Gemini: { inlineData: { data: '...' } } | { inline_data: { data: '...' } } +function matchesGeminiInlineDataShape(node: Record): boolean { + const inlineData = node.inlineData ?? node.inline_data; + if (!inlineData || typeof inlineData !== "object") return false; + return typeof (inlineData as Record).data === "string"; +} + /** - * Estimate token count from text length + * Detect the 5 documented inline-base64 image content-block shapes (see the + * shape-specific matchers above). + */ +function isInlineBase64ImageBlock(node: Record): boolean { + return ( + matchesOpenAIImageUrlShape(node) || + matchesAiSdkImageShape(node) || + matchesClaudeSourceShape(node) || + matchesGeminiInlineDataShape(node) + ); +} + +/** + * Recursively walk a structured node, replacing every recognized inline + * base64 image block with a short placeholder (so its bulk is excluded from + * the char-count pass below) while accumulating a bounded per-image token + * cost. Returns the accumulated image token cost; the caller measures the + * placeholder-substituted structure with the normal char/4 heuristic. + * + * Non-image content (including remote image URLs and generic base64 text) + * is left untouched and continues to flow through the text-estimation path. + */ +function extractImageTokens(node: unknown, seen: Set): { node: unknown; tokens: number } { + if (node === null || typeof node !== "object") { + return { node, tokens: 0 }; + } + // Guard against cycles in structured request bodies. + if (seen.has(node)) return { node, tokens: 0 }; + seen.add(node); + + if (Array.isArray(node)) { + let tokens = 0; + const out = node.map((item) => { + const record = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : null; + if (record && isInlineBase64ImageBlock(record)) { + tokens += IMAGE_TOKEN_ESTIMATE; + return { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE }; + } + const result = extractImageTokens(item, seen); + tokens += result.tokens; + return result.node; + }); + return { node: out, tokens }; + } + + const record = node as Record; + if (isInlineBase64ImageBlock(record)) { + return { node: { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE }, tokens: IMAGE_TOKEN_ESTIMATE }; + } + + let tokens = 0; + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + const result = extractImageTokens(value, seen); + out[key] = result.node; + tokens += result.tokens; + } + return { node: out, tokens }; +} + +/** + * Estimate token count from text length. + * + * Structured input is first walked for inline base64 image blocks (#8368): + * each recognized image block is substituted with a bounded per-image token + * budget instead of measuring its base64 payload as raw text, then the + * remainder of the structure is measured normally via the char/4 heuristic. */ export function estimateTokens(text: string | object | null | undefined): number { if (!text) return 0; - const str = typeof text === "string" ? text : JSON.stringify(text); - return Math.ceil(str.length / CHARS_PER_TOKEN); + if (typeof text === "string") { + return Math.ceil(text.length / CHARS_PER_TOKEN); + } + const { node, tokens: imageTokens } = extractImageTokens(text, new Set()); + const str = JSON.stringify(node); + return Math.ceil(str.length / CHARS_PER_TOKEN) + imageTokens; } /** diff --git a/tests/unit/8368-image-token-context.test.ts b/tests/unit/8368-image-token-context.test.ts new file mode 100644 index 0000000000..947be5eace --- /dev/null +++ b/tests/unit/8368-image-token-context.test.ts @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { estimateTokens, getTokenLimit } from "../../open-sse/services/contextManager.ts"; + +function makeFakePngBase64(approxBytes: number): string { + return Buffer.alloc(approxBytes, 65).toString("base64"); +} + +test("#8368: inline base64 PNG image_url is NOT counted as raw text (bounded image-token estimate)", () => { + const base64 = makeFakePngBase64(1_900_000); // ~1.9MB, matches issue repro + const messages = [ + { role: "user", content: "Please describe this image." }, + { + role: "user", + content: [ + { type: "input_text", text: "Please describe this image." }, + { type: "input_image", image_url: `data:image/png;base64,${base64}` }, + ], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok( + estimated < 5000, + `BUG #8368 reproduced: image-bearing message estimated at ${estimated} tokens (limit ${getTokenLimit( + "codex" + )})` + ); +}); + +test("#8368: plain text estimation is unaffected by the image-token fix (control)", () => { + const text = "a".repeat(4000); // 4000 chars => ~1000 tokens at CHARS_PER_TOKEN=4 + const estimated = estimateTokens(text); + assert.equal(estimated, 1000); +}); + +test("#8368: OpenAI chat.completions image_url object shape is bounded", () => { + const base64 = makeFakePngBase64(500_000); + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64}` } }, + ], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok(estimated < 5000, `expected bounded estimate, got ${estimated}`); +}); + +test("#8368: Claude source.base64 image block shape is bounded", () => { + const base64 = makeFakePngBase64(500_000); + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "Describe this." }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: base64 }, + }, + ], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok(estimated < 5000, `expected bounded estimate, got ${estimated}`); +}); + +test("#8368: Gemini inlineData image block shape is bounded", () => { + const base64 = makeFakePngBase64(500_000); + const messages = [ + { + role: "user", + parts: [{ text: "Describe this." }, { inlineData: { mimeType: "image/png", data: base64 } }], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok(estimated < 5000, `expected bounded estimate, got ${estimated}`); +}); + +test("#8368: multiple images accumulate a bounded sum, not one flat cap", () => { + const base64 = makeFakePngBase64(200_000); + const oneImageMessages = [ + { role: "user", content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }] }, + ]; + const threeImageMessages = [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + ], + }, + ]; + const one = estimateTokens(oneImageMessages); + const three = estimateTokens(threeImageMessages); + assert.ok(three > one, `expected 3 images to cost more than 1 (one=${one}, three=${three})`); + assert.ok(three < one * 4, `expected roughly linear scaling, got one=${one} three=${three}`); +}); + +test("#8368: remote http(s) image URLs are unaffected (still measured as text)", () => { + const messages = [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/cat.png" } }], + }, + ]; + const estimated = estimateTokens(messages); + // Should just be the JSON-length/4 heuristic for the short URL string, not near-zero + // and not a bounded image-token substitute — remote URLs stay on the text path. + assert.ok(estimated > 0 && estimated < 100, `expected small text-based estimate, got ${estimated}`); +}); + +test("#8368: generic long base64 text (not an image field) still uses the text path", () => { + const genericBase64 = makeFakePngBase64(100_000); + const estimated = estimateTokens(genericBase64); + const expectedTextEstimate = Math.ceil(genericBase64.length / 4); + assert.equal(estimated, expectedTextEstimate); +}); From 09e9ecef975d017c9ed8fad7f645488f983fad8d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:37:31 -0300 Subject: [PATCH 08/83] fix(resilience): treat an unreachable-proxy ECONNREFUSED as a circuit-breaker event so combo fails over instead of hitting the 503 max-retry limit (#8376) (#8403) --- .../fixes/8376-econnrefused-breaker.md | 1 + open-sse/handlers/chatCore.ts | 24 +++-- open-sse/services/combo.ts | 12 +-- open-sse/services/combo/comboPredicates.ts | 12 ++- open-sse/utils/proxyFetch.ts | 43 ++++++++- tests/unit/8376-econnrefused-breaker.test.ts | 96 +++++++++++++++++++ 6 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 changelog.d/fixes/8376-econnrefused-breaker.md create mode 100644 tests/unit/8376-econnrefused-breaker.test.ts diff --git a/changelog.d/fixes/8376-econnrefused-breaker.md b/changelog.d/fixes/8376-econnrefused-breaker.md new file mode 100644 index 0000000000..4e9756c203 --- /dev/null +++ b/changelog.d/fixes/8376-econnrefused-breaker.md @@ -0,0 +1 @@ +- fix(resilience): treat an unreachable-proxy ECONNREFUSED as a circuit-breaker event so combo fails over instead of hitting the 503 max-retry limit (#8376) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5d7f3d5dbc..2cbb8eb569 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3214,17 +3214,29 @@ export async function handleChatCore({ // via isLocalStreamLifecycleError so those map to 499 instead of falling // through to the 502 provider-failure default. const isRequestAborted = isLocalStreamLifecycleError(error); + // #8376: an unreachable upstream proxy (ECONNREFUSED/ECONNRESET/...) is tagged by + // proxyFetch.ts (tagProxyUnreachable) with `.errorCode = "proxy_unreachable"` before + // it reaches this catch. Classify it explicitly to 502 instead of falling through + // the generic `error.status` branch (a raw connect-refused error has no `.status` at + // all, so it used to collapse into an ordinary 502/504 the provider-breaker predicate + // can't tell apart from a per-model 5xx). + const isProxyUnreachableFailure = + !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable"; const failureStatus = isRequestAborted ? 499 - : error.name === "TimeoutError" || error.name === "BodyTimeoutError" - ? HTTP_STATUS.GATEWAY_TIMEOUT - : error.status && typeof error.status === "number" - ? error.status - : HTTP_STATUS.BAD_GATEWAY; + : isProxyUnreachableFailure + ? HTTP_STATUS.BAD_GATEWAY + : error.name === "TimeoutError" || error.name === "BodyTimeoutError" + ? HTTP_STATUS.GATEWAY_TIMEOUT + : error.status && typeof error.status === "number" + ? error.status + : HTTP_STATUS.BAD_GATEWAY; const failureMessage = isRequestAborted ? "Request aborted" : formatProviderError(error, provider, model, failureStatus); - const upstreamErrorCode = getUpstreamErrorIdentifier(error); + const upstreamErrorCode = isProxyUnreachableFailure + ? "proxy_unreachable" + : getUpstreamErrorIdentifier(error); // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 5441ebf8f5..1743ee0ea1 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -2381,12 +2381,11 @@ export async function handleComboChat({ return { ok: false, response: result }; } - // Trigger shared provider circuit breaker for 5xx errors and connection failures. - // If the next target in the combo is on the same provider, don't mark the provider - // as failed — different models on the same provider may still succeed. - // G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor - // outage signalled via X-Omni-Fallback-Hint: connection_cooldown) apply connection - // cooldown only — do NOT trip the whole-provider breaker. + // Trigger shared provider circuit breaker for 5xx errors and connection failures. If the + // next target is on the same provider, don't mark it failed (a different model may still + // succeed) — #8376: EXCEPT a proxy-unreachable failure, which poisons every model alike. + // G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor outage + // signalled via X-Omni-Fallback-Hint: connection_cooldown) apply cooldown only — never trip. const nextTarget = orderedTargets[i + 1]; const sameProviderNext = typeof nextTarget?.provider === "string" && nextTarget.provider === provider; @@ -2398,6 +2397,7 @@ export async function handleComboChat({ skipProviderBreaker: fallbackResult.skipProviderBreaker, requestScopedFailure, error: errorText, + isProxyUnreachable: structuredError?.code === "proxy_unreachable", }) ) { recordProviderFailure(provider, log, targetWithConnection.connectionId, profile); diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 315a49a5a2..f7f0f11321 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -140,7 +140,12 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); * this intentionally differs from `isProviderFailureCode` (accountFallback.ts), which * INCLUDES 429 for connection-cooldown purposes and must not be changed here. * - When the next combo target is on the SAME provider, don't trip the provider breaker: - * a different model on that provider may still succeed. + * a different model on that provider may still succeed. #8376: EXCEPT when the failure + * itself is a transport-level "proxy unreachable" event (`isProxyUnreachable`) — a dead + * upstream proxy poisons every account on that provider identically, so a different + * model on the same provider will fail the exact same way. Without this override a + * homogeneous same-provider combo pool never trips the breaker and instead burns every + * attempt against the same dead proxy until it hits the 503 max-retry limit. * - G-02 / #2743: when the fallback result carries `skipProviderBreaker` (an embedded * service supervisor outage signalled via `X-Omni-Fallback-Hint: connection_cooldown`) * apply connection cooldown ONLY — never trip the whole-provider breaker. @@ -161,11 +166,14 @@ export function shouldRecordProviderBreakerFailure(args: { skipProviderBreaker?: boolean; requestScopedFailure?: boolean; error?: unknown; + /** #8376: transport-level "proxy unreachable" signal — overrides the `sameProviderNext` + * exemption only; every other AND-term still gates the trip. */ + isProxyUnreachable?: boolean; }): boolean { return ( !args.isStreamReadinessFailure && PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) && - !args.sameProviderNext && + (!args.sameProviderNext || args.isProxyUnreachable === true) && !args.skipProviderBreaker && !args.requestScopedFailure && !isLocalStreamLifecycleError(args.error) diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index bddfa207bf..8eea0d2774 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -22,6 +22,45 @@ function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; } +// #8376: transport-level connect-failure codes that mean "the configured upstream +// proxy (or the target itself, for direct egress) is unreachable" — as opposed to an +// ordinary upstream HTTP error. Read `.code` first (stable across undici/node +// versions); native fetch wraps the real socket error in `.cause`, so fall back to +// `.cause.code` when the top-level error is a bare "fetch failed" TypeError. +const PROXY_UNREACHABLE_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "ENETUNREACH", + "EHOSTUNREACH", + "EPIPE", + "UND_ERR_CONNECT_TIMEOUT", +]); + +function isProxyUnreachableError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + if (typeof code === "string" && PROXY_UNREACHABLE_ERROR_CODES.has(code)) return true; + const causeCode = (err as { cause?: { code?: unknown } }).cause?.code; + return typeof causeCode === "string" && PROXY_UNREACHABLE_ERROR_CODES.has(causeCode); +} + +/** + * #8376: tag a connect-failure error with a stable `.code`/`.errorCode` BEFORE it is + * rethrown, so chatCore's catch block (and, through the response body, the combo + * provider-breaker predicate) can classify it as "proxy unreachable" instead of + * falling through to a generic 502 that never trips the whole-provider breaker on a + * homogeneous same-provider combo pool. No-op when the error isn't connect-shaped. + */ +function tagProxyUnreachable(err: T): T { + if (isProxyUnreachableError(err)) { + const e = err as Error & { code?: string; errorCode?: string }; + e.code = e.code || "PROXY_UNREACHABLE"; + e.errorCode = "proxy_unreachable"; + } + return err; +} + /** Per-request tracking of whether TLS fingerprint was used */ type TlsFingerprintStore = { used: boolean }; const tlsFingerprintContext = new AsyncLocalStorage(); @@ -530,7 +569,7 @@ async function patchedFetch( if (dispatcherError instanceof Error) { (dispatcherError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail; } - throw dispatcherError; + throw tagProxyUnreachable(dispatcherError); } // All attempts exhausted — try proxy fallback before native fetch @@ -573,7 +612,7 @@ async function patchedFetch( if (nativeError instanceof Error) { (nativeError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail; } - throw nativeError; + throw tagProxyUnreachable(nativeError); } } throw dispatcherError; diff --git a/tests/unit/8376-econnrefused-breaker.test.ts b/tests/unit/8376-econnrefused-breaker.test.ts new file mode 100644 index 0000000000..6d99ae4e39 --- /dev/null +++ b/tests/unit/8376-econnrefused-breaker.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { shouldRecordProviderBreakerFailure } from "../../open-sse/services/combo/comboPredicates.ts"; + +// #8376 — an unreachable upstream proxy (ECONNREFUSED) on a homogeneous same-provider +// combo pool must still trip the whole-provider circuit breaker so combo routing fails +// over to a different provider, instead of burning MAX_GLOBAL_ATTEMPTS against the same +// dead proxy and returning 503 "Maximum combo retry limit". + +test("#8376: proxy-unreachable failure on a homogeneous same-provider combo trips the breaker via isProxyUnreachable override", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: true, + }); + assert.equal(result, true); +}); + +test("#8376 control: without the override, the SAME same-provider failure still does not trip (proves the override is additive, not a blanket bypass)", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: false, + }); + assert.equal(result, false); +}); + +test("#8376: the override never bypasses the other AND-terms — a stream-readiness failure still does not trip even when isProxyUnreachable is true", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: true, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); + +test("#8376: the override never bypasses skipProviderBreaker (embedded-service connection-cooldown-only hint) even when isProxyUnreachable is true", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: true, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); + +test("#8376: a genuine same-provider 5xx (not proxy-unreachable) still does NOT trip the breaker — no over-widening", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "upstream returned 502", + }); + assert.equal(result, false); +}); + +test("#8376: a normal 200-derived non-breaker-status failure is unaffected by isProxyUnreachable being true (status gate still applies)", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 200, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); + +test("#8376: a normal 429 (rate limit) is unaffected by isProxyUnreachable being true (429 intentionally excluded from breaker statuses)", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 429, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); From 1cafd328c73d9d51dc6b65959e3361d5f4323b69 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:37:37 -0300 Subject: [PATCH 09/83] fix(dashboard): persist compression engine detail settings (Headroom / session dedup / CCR) instead of dropping them on save (#8388) (#8404) --- .../fixes/8388-compression-detail-persist.md | 1 + config/quality/file-size-baseline.json | 3 +- .../services/compression/stepDetailConfig.ts | 26 +++++++ .../services/compression/strategySelector.ts | 12 ++-- open-sse/services/compression/types.ts | 37 ++++++++++ src/lib/db/compression.ts | 6 ++ src/lib/db/compressionDetailNormalizers.ts | 70 +++++++++++++++++++ .../compression/EngineConfigPage.tsx | 11 +-- .../validation/compressionConfigSchemas.ts | 20 ++++++ .../8388-compression-detail-persist.test.ts | 64 +++++++++++++++++ 10 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fixes/8388-compression-detail-persist.md create mode 100644 open-sse/services/compression/stepDetailConfig.ts create mode 100644 src/lib/db/compressionDetailNormalizers.ts create mode 100644 tests/unit/8388-compression-detail-persist.test.ts diff --git a/changelog.d/fixes/8388-compression-detail-persist.md b/changelog.d/fixes/8388-compression-detail-persist.md new file mode 100644 index 0000000000..da963af987 --- /dev/null +++ b/changelog.d/fixes/8388-compression-detail-persist.md @@ -0,0 +1 @@ +- fix(dashboard): persist compression engine detail settings (Headroom / session dedup / CCR) instead of dropping them on save (#8388) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0ff32a4ea2..f2901f608d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -186,7 +186,8 @@ "open-sse/handlers/videoGeneration.ts": 1275, "_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \"codex-responses\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \"codex-responses\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \"codex-responses\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).", "_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.", - "src/lib/db/compression.ts": 866, + "_rebaseline_2026_07_24_8388_compression_detail_persist": "#8388 (compression engine DETAIL settings — Headroom/session-dedup/CCR — dropped on save) own growth: src/lib/db/compression.ts 866->872 (+6 = irreducible call-site wiring at the existing getCompressionSettings/updateCompressionSettings chokepoint: one import line, one `...buildDetailConfigDefaults()` spread in the seed config, and one `case \"sessionDedup\": case \"ccr\": applyDetailConfigUpdate(config, key, parsed); break;` load-switch case, mirroring the existing headroom/#8056 case immediately above it). The actual normalizer logic (normalizeSessionDedupConfig/normalizeCcrConfig, matching SESSION_DEDUP_SCHEMA/CCR_SCHEMA bounds) was EXTRACTED into a new leaf src/lib/db/compressionDetailNormalizers.ts (well under cap) so this frozen file only carries the minimal dispatch wiring. Covered by tests/unit/8388-compression-detail-persist.test.ts (schema-accept + full DB save->reload round-trip for both new sub-objects, plus a no-regression assertion on the existing headroom round-trip).", + "src/lib/db/compression.ts": 872, "open-sse/mcp-server/schemas/tools.ts": 1505, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1120, diff --git a/open-sse/services/compression/stepDetailConfig.ts b/open-sse/services/compression/stepDetailConfig.ts new file mode 100644 index 0000000000..d1ba993641 --- /dev/null +++ b/open-sse/services/compression/stepDetailConfig.ts @@ -0,0 +1,26 @@ +// Resolves the persisted per-engine DETAIL sub-object (settings.headroom / .sessionDedup / +// .ccr) for a stacked-pipeline step. Extracted out of strategySelector.ts (frozen at cap by +// file-size-baseline.json — see scripts/check/check-file-size.mjs) rather than growing that +// file inline. +// +// #8056 wired settings.headroom.minRows into buildStepOptions so the dashboard value takes +// effect even when the stacked-pipeline step itself carries no config. #8388 extends the same +// merge to session-dedup and ccr, whose detail settings previously had nowhere to persist to +// (see compressionDetailNormalizers.ts on the DB write side of the same gap). +import type { CompressionConfig, CompressionPipelineStep } from "./types.ts"; + +export function resolveStepDetailConfig( + engine: CompressionPipelineStep["engine"], + config: CompressionConfig | undefined +): Record { + switch (engine) { + case "headroom": + return (config?.headroom as Record | undefined) ?? {}; + case "session-dedup": + return (config?.sessionDedup as Record | undefined) ?? {}; + case "ccr": + return (config?.ccr as Record | undefined) ?? {}; + default: + return {}; + } +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 38c2779777..8785ddb550 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -28,6 +28,7 @@ import { decideStep, mergeStackStep, } from "./stackedStepCore.ts"; +import { resolveStepDetailConfig } from "./stepDetailConfig.ts"; import { registerBuiltinCompressionEngines } from "./engines/index.ts"; import { getCompressionEngine, getEngineEntry } from "./engines/registry.ts"; import { codexResponsesEngine } from "./engines/codexResponses/index.ts"; @@ -736,13 +737,12 @@ function buildStepOptions( step: CompressionPipelineStep, options?: StackOptions ): CompressionEngineApplyOptions { - // Headroom detail (minRows) lives on settings.headroom, not only on step.config. - // Merge it so the stacked runner honors the dashboard value (#8056). Explicit - // step.config still wins so combo pipelines can override per step. - const headroomDetail = - step.engine === "headroom" ? (options?.config?.headroom ?? {}) : {}; + // Detail sub-objects (headroom.minRows #8056; sessionDedup/ccr #8388) live on + // settings., not only on step.config. Merge them so the stacked runner + // honors the dashboard value. Explicit step.config still wins so combo pipelines + // can override per step. See resolveStepDetailConfig (stepDetailConfig.ts). const stepConfig: Record = { - ...headroomDetail, + ...resolveStepDetailConfig(step.engine, options?.config), ...(step.config ?? {}), ...(step.intensity ? { intensity: step.intensity } : {}), }; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 66e318371d..665af5988f 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -220,6 +220,10 @@ export interface CompressionConfig { ultra?: UltraConfig; /** Headroom SmartCrusher detail settings (minRows gate). */ headroom?: HeadroomConfig; + /** Session Dedup detail settings (minBlockChars / fuzzy, #8388). */ + sessionDedup?: SessionDedupConfig; + /** CCR (context-cache-retrieval) detail settings (minChars / retrievalRampFactor, #8388). */ + ccr?: CcrConfig; /** Provider-delegated context editing (Claude/Anthropic only). */ contextEditing?: ContextEditingConfig; /** Opt-in cache-aligned live-zone compression (default disabled). */ @@ -563,6 +567,39 @@ export const DEFAULT_HEADROOM_CONFIG: HeadroomConfig = { minRows: 8, }; +// ─── Session Dedup detail settings ─────────────────────────────────────────── +// Persisted under compression settings key `sessionDedup` (#8388 — was previously +// rendered on the detail page but had no sub-object to save into). + +/** Configuration for the Session Dedup engine detail page. */ +export interface SessionDedupConfig { + /** Minimum character count for a suffix block to be a dedup candidate. Matches DEFAULT_MIN_BLOCK_CHARS=80. */ + minBlockChars: number; + /** Opt-in fuzzy near-duplicate dedup (replaces ~85%+ similar messages with a CCR marker). */ + fuzzy: boolean; +} + +export const DEFAULT_SESSION_DEDUP_CONFIG: SessionDedupConfig = { + minBlockChars: 80, + fuzzy: false, +}; + +// ─── CCR (context-cache-retrieval) detail settings ─────────────────────────── +// Persisted under compression settings key `ccr` (#8388 — same gap as session-dedup). + +/** Configuration for the CCR engine detail page. */ +export interface CcrConfig { + /** Minimum character count for a block to be a CCR candidate. Matches DEFAULT_MIN_CHARS=600. */ + minChars: number; + /** How steeply frequently-retrieved blocks resist compression; 1 disables the ramp. */ + retrievalRampFactor: number; +} + +export const DEFAULT_CCR_CONFIG: CcrConfig = { + minChars: 600, + retrievalRampFactor: 2, +}; + export type { McpAccessibilityConfig } from "./engines/mcpAccessibility/constants.ts"; export { DEFAULT_MCP_ACCESSIBILITY_CONFIG, diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 3b39ccd9e4..7c9b46732c 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -40,6 +40,7 @@ import { normalizePreserveSystemPromptMode, } from "@omniroute/open-sse/services/compression/preserveSystemPromptMode.ts"; import { maybePrewarmUltraSlmOnConfig } from "@omniroute/open-sse/services/compression/ultra.ts"; +import { applyDetailConfigUpdate, buildDetailConfigDefaults } from "./compressionDetailNormalizers"; const NAMESPACE = "compression"; const COMPRESSION_MODES = new Set([ @@ -612,6 +613,7 @@ export async function getCompressionSettings(): Promise { aggressive: normalizeAggressiveConfig(undefined), ultra: normalizeUltraConfig(undefined), headroom: normalizeHeadroomConfig(undefined), + ...buildDetailConfigDefaults(), contextBudget: normalizeContextBudgetConfig(undefined), contextEditing: { ...DEFAULT_CONTEXT_EDITING_CONFIG }, liveZone: { enabled: false }, @@ -724,6 +726,10 @@ export async function getCompressionSettings(): Promise { case "headroomConfig": config.headroom = normalizeHeadroomConfig(parsed); break; + case "sessionDedup": + case "ccr": + applyDetailConfigUpdate(config, key, parsed); + break; case "contextBudget": config.contextBudget = normalizeContextBudgetConfig(parsed); break; diff --git a/src/lib/db/compressionDetailNormalizers.ts b/src/lib/db/compressionDetailNormalizers.ts new file mode 100644 index 0000000000..6da34a9153 --- /dev/null +++ b/src/lib/db/compressionDetailNormalizers.ts @@ -0,0 +1,70 @@ +// Normalizers for the compression engine DETAIL settings sub-objects that persist to a +// single key_value row each (settings.sessionDedup / settings.ccr). Extracted out of +// src/lib/db/compression.ts (frozen at cap by file-size-baseline.json — see +// scripts/check/check-file-size.mjs) rather than growing that file inline. +// +// #8388: session-dedup and ccr detail fields (minBlockChars/fuzzy, minChars/ +// retrievalRampFactor) were editable on the EngineConfigPage detail form but had no +// persisted sub-object — mirrors the #8056 headroom/minRows fix (normalizeHeadroomConfig +// in compression.ts), extended to the two engines #8056 left uncovered. +import { + DEFAULT_CCR_CONFIG, + DEFAULT_SESSION_DEDUP_CONFIG, + type CcrConfig, + type CompressionConfig, + type SessionDedupConfig, +} from "@omniroute/open-sse/services/compression/types.ts"; + +function toRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +function boundedInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +/** Matches SESSION_DEDUP_SCHEMA bounds (engines/session-dedup/index.ts). */ +export function normalizeSessionDedupConfig(value: unknown): SessionDedupConfig { + const record = toRecord(value); + return { + ...DEFAULT_SESSION_DEDUP_CONFIG, + minBlockChars: boundedInt( + record.minBlockChars, + DEFAULT_SESSION_DEDUP_CONFIG.minBlockChars, + 1, + 100000 + ), + fuzzy: typeof record.fuzzy === "boolean" ? record.fuzzy : DEFAULT_SESSION_DEDUP_CONFIG.fuzzy, + }; +} + +/** Matches CCR_SCHEMA bounds (engines/ccr/index.ts). */ +export function normalizeCcrConfig(value: unknown): CcrConfig { + const record = toRecord(value); + return { + ...DEFAULT_CCR_CONFIG, + minChars: boundedInt(record.minChars, DEFAULT_CCR_CONFIG.minChars, 100, 1_000_000), + retrievalRampFactor: boundedInt( + record.retrievalRampFactor, + DEFAULT_CCR_CONFIG.retrievalRampFactor, + 1, + 100 + ), + }; +} + +/** Default sub-objects spread into getCompressionSettings' seed config. */ +export function buildDetailConfigDefaults(): Pick { + return { sessionDedup: normalizeSessionDedupConfig(undefined), ccr: normalizeCcrConfig(undefined) }; +} + +/** Applies a stored sessionDedup/ccr row onto config during getCompressionSettings' row scan. */ +export function applyDetailConfigUpdate( + config: CompressionConfig, + key: "sessionDedup" | "ccr", + parsed: unknown +): void { + if (key === "sessionDedup") config.sessionDedup = normalizeSessionDedupConfig(parsed); + else config.ccr = normalizeCcrConfig(parsed); +} diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 79b5859ae5..5c1981a4ec 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -21,14 +21,17 @@ interface EngineEntry { // Engines whose detailed config has a dedicated sub-object in the compression // settings store. The on/off + level for ALL engines now live in the panel // (/dashboard/context/settings, the `engines` map); only these have a place to -// persist the extra per-engine fields edited on this page. Other structural -// engines (lite, session-dedup, ccr, llmlingua, relevance) still have no -// dedicated sub-object — their page keeps the detail form + preview but has -// nothing extra to persist yet. +// persist the extra per-engine fields edited on this page. session-dedup and ccr +// joined headroom in #8388 (they previously rendered a real, editable detail form +// with no Save affordance — edits vanished on reload). Other structural engines +// (lite, llmlingua, relevance) still have no dedicated sub-object — their page +// keeps the detail form + preview but has nothing extra to persist yet. const SETTINGS_SUBOBJECT: Record = { aggressive: "aggressive", ultra: "ultra", headroom: "headroom", + "session-dedup": "sessionDedup", + ccr: "ccr", }; interface CompressionSettings { diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index 2b26585f97..74c56d275a 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -161,6 +161,24 @@ export const headroomConfigSchema = z }) .strict(); +// Session Dedup / CCR detail settings (#8388 — sibling gap to headroom/#8056: the +// EngineConfigPage detail form was renderable but PUT bodies had no slot to persist +// into). Ranges mirror SESSION_DEDUP_SCHEMA / CCR_SCHEMA (engines/session-dedup, +// engines/ccr) so the validation layer stays in lockstep with the engine's own bounds. +export const sessionDedupConfigSchema = z + .object({ + minBlockChars: z.number().int().min(1).max(100000).optional(), + fuzzy: z.boolean().optional(), + }) + .strict(); + +export const ccrConfigSchema = z + .object({ + minChars: z.number().int().min(100).max(1_000_000).optional(), + retrievalRampFactor: z.number().min(1).max(100).optional(), + }) + .strict(); + const noConfigSchema = z.object({}).strict(); // Structural engines (session-dedup / ccr / headroom / relevance / llmlingua) do not @@ -344,6 +362,8 @@ export const compressionSettingsUpdateSchema = z aggressive: aggressiveConfigSchema.optional(), ultra: ultraConfigSchema.optional(), headroom: headroomConfigSchema.optional(), + sessionDedup: sessionDedupConfigSchema.optional(), + ccr: ccrConfigSchema.optional(), contextBudget: contextBudgetConfigSchema.optional(), contextEditing: contextEditingConfigSchema.optional(), liveZone: z.object({ enabled: z.boolean() }).strict().optional(), diff --git a/tests/unit/8388-compression-detail-persist.test.ts b/tests/unit/8388-compression-detail-persist.test.ts new file mode 100644 index 0000000000..c65d29ba95 --- /dev/null +++ b/tests/unit/8388-compression-detail-persist.test.ts @@ -0,0 +1,64 @@ +// #8388 — Compression engine DETAIL settings (Headroom / session dedup / CCR) do not +// persist on save. Root cause was a two-layer gap on origin/release/v3.8.49: +// (1) the .strict() Zod schema (compressionSettingsUpdateSchema) had no `sessionDedup` +// / `ccr` top-level keys, so a PUT body carrying either was rejected outright; +// (2) even past validation, src/lib/db/compression.ts had no normalizer/switch-case +// wired for those two sub-objects (only `headroom` got the #8056 treatment), so a +// set → save → reload round-trip would silently drop the values. +// This test asserts the FULL round-trip end-to-end (schema parse -> DB write -> DB read), +// not just schema-level parsing, per the plan-file's explicit instruction. +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"; + +// Isolate DATA_DIR so this test never touches a real installed DB (see MEMORY: "teste sem +// isolateDataDir → DB REAL"). Must be set BEFORE importing anything that resolves getDbInstance(). +const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8388-")); +process.env.DATA_DIR = tmpDataDir; + +const { compressionSettingsUpdateSchema } = await import( + "../../src/shared/validation/compressionConfigSchemas.ts" +); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = await import( + "../../src/lib/db/compression.ts" +); + +test.after(() => { + resetDbInstance(); + fs.rmSync(tmpDataDir, { recursive: true, force: true }); +}); + +test("#8388: PUT body carrying ccr detail (minChars/retrievalRampFactor) is ACCEPTED by the schema", () => { + const parsed = compressionSettingsUpdateSchema.safeParse({ + ccr: { minChars: 5000, retrievalRampFactor: 10 }, + }); + assert.equal(parsed.success, true); +}); + +test("#8388: PUT body carrying session-dedup detail (minBlockChars/fuzzy) is ACCEPTED by the schema", () => { + const parsed = compressionSettingsUpdateSchema.safeParse({ + sessionDedup: { minBlockChars: 200, fuzzy: true }, + }); + assert.equal(parsed.success, true); +}); + +test("#8388: session-dedup detail round-trips through save -> reload (DB layer)", async () => { + await updateCompressionSettings({ sessionDedup: { minBlockChars: 321, fuzzy: true } }); + const reloaded = await getCompressionSettings(); + assert.deepEqual(reloaded.sessionDedup, { minBlockChars: 321, fuzzy: true }); +}); + +test("#8388: ccr detail round-trips through save -> reload (DB layer)", async () => { + await updateCompressionSettings({ ccr: { minChars: 4242, retrievalRampFactor: 7 } }); + const reloaded = await getCompressionSettings(); + assert.deepEqual(reloaded.ccr, { minChars: 4242, retrievalRampFactor: 7 }); +}); + +test("#8388: headroom minRows STILL round-trips (proves #8056 fix stays intact, no regression)", async () => { + await updateCompressionSettings({ headroom: { minRows: 5 } }); + const reloaded = await getCompressionSettings(); + assert.deepEqual(reloaded.headroom, { minRows: 5 }); +}); From b8901b650662cc5800d47aabb87c580e762db6f1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 25 Jul 2026 02:50:40 -0300 Subject: [PATCH 10/83] feat(db): persist caller session tag into call_logs for per-session cost attribution (#8249) (#8334) --- .../features/8249-call-logs-session-tag.md | 1 + docs/reference/API_REFERENCE.md | 1 + open-sse/handlers/chatCore.ts | 9 +- open-sse/handlers/chatCore/attemptLogging.ts | 6 + .../migrations/133_call_logs_session_tag.sql | 2 + src/lib/db/schemaColumns.ts | 5 + src/lib/usage/callLogs.ts | 29 +++- tests/unit/call-logs-session-tag.test.ts | 132 ++++++++++++++++++ 8 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 changelog.d/features/8249-call-logs-session-tag.md create mode 100644 src/lib/db/migrations/133_call_logs_session_tag.sql create mode 100644 tests/unit/call-logs-session-tag.test.ts diff --git a/changelog.d/features/8249-call-logs-session-tag.md b/changelog.d/features/8249-call-logs-session-tag.md new file mode 100644 index 0000000000..d747081fe8 --- /dev/null +++ b/changelog.d/features/8249-call-logs-session-tag.md @@ -0,0 +1 @@ +- feat(db): persist caller session tag into call_logs for per-session cost attribution (#8249) diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 75bcf3d5fd..32aab3fbe8 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -68,6 +68,7 @@ Content-Type: application/json | `X-OmniRoute-Progress` | Request | Set to `true` for progress events | | `X-Session-Id` | Request | Sticky session key for external session affinity | | `x_session_id` | Request | Underscore variant also accepted (direct HTTP) | +| `X-OmniRoute-Session-Id` | Request | Caller-supplied session/conversation tag (also feeds memory). When present, persisted verbatim to `call_logs.session_tag` for per-session cost attribution (#8249) — never synthesized when absent | | `Idempotency-Key` | Request | Dedup key (5s window) | | `X-Request-Id` | Request | Alternative dedup key | | `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) | diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2cbb8eb569..7ca0e4b23c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -857,13 +857,17 @@ export async function handleChatCore({ pendingWrite: compressionAnalyticsWritePromise, skillRequestId, }); - const pipelineSessionId = + // #8249: raw header value, kept separate from `pipelineSessionId`'s skillRequestId fallback + // below so call_logs.session_tag is only ever set when the caller explicitly supplied the + // header — never synthesized from the internal per-request skillRequestId. + const explicitSessionIdHeader = (clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" ? clientRawRequest.headers.get("x-omniroute-session-id") : getHeaderValueCaseInsensitive( clientRawRequest?.headers ?? null, "x-omniroute-session-id" - )) || skillRequestId; + )) || null; + const pipelineSessionId = explicitSessionIdHeader || skillRequestId; // persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context // once so the 16 call sites keep passing only the per-attempt args (byte-identical). const persistAttemptLogs = (args: PersistAttemptLogsArgs) => @@ -891,6 +895,7 @@ export async function handleChatCore({ noLogEnabled, correlationId, modelPinned, + sessionTag: explicitSessionIdHeader, }); // Primary path: merge client model id + alias target so config on either key applies; resolved diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index c6e46c8dc3..245aafdcc3 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -57,6 +57,10 @@ export type PersistAttemptLogsContext = { noLogEnabled: unknown; correlationId?: string | null; modelPinned?: boolean; + /** #8249: caller-supplied X-OmniRoute-Session-Id header, only set when the header was + * explicitly present (never synthesized from skillRequestId) — persisted as call_logs.session_tag + * for per-session cost attribution. */ + sessionTag?: string | null; }; function toConnectionId(value: unknown): string | null { @@ -171,6 +175,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt noLogEnabled, correlationId, modelPinned, + sessionTag, } = ctx; const initialConnectionId = toConnectionId(connectionId); const finalConnectionId = toConnectionId(credentials?.connectionId) || initialConnectionId; @@ -270,6 +275,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt pipelinePayloads, correlationId, modelPinned: modelPinned || false, + sessionTag: sessionTag || null, }).catch(() => {}); // Emit the terminal request-lifecycle event to the live dashboard bus. `request.started` diff --git a/src/lib/db/migrations/133_call_logs_session_tag.sql b/src/lib/db/migrations/133_call_logs_session_tag.sql new file mode 100644 index 0000000000..13408b1b53 --- /dev/null +++ b/src/lib/db/migrations/133_call_logs_session_tag.sql @@ -0,0 +1,2 @@ +ALTER TABLE call_logs ADD COLUMN session_tag TEXT DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_cl_session_tag ON call_logs(session_tag); diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index d9334f05f5..a5fbd0b62a 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -253,6 +253,10 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE call_logs ADD COLUMN model_pinned INTEGER DEFAULT 0"); console.log("[DB] Added call_logs.model_pinned column"); } + if (!columnNames.has("session_tag")) { + db.exec("ALTER TABLE call_logs ADD COLUMN session_tag TEXT DEFAULT NULL"); + console.log("[DB] Added call_logs.session_tag column"); + } db.exec( "CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model ON call_logs(requested_model)" @@ -262,6 +266,7 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { "CREATE INDEX IF NOT EXISTS idx_cl_combo_target ON call_logs(combo_name, combo_execution_key, timestamp)" ); db.exec("CREATE INDEX IF NOT EXISTS idx_cl_correlation_id ON call_logs(correlation_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_cl_session_tag ON call_logs(session_tag)"); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify call_logs schema:", message); diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 7f555b7791..26ed4f6872 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -93,6 +93,7 @@ type CallLogSummaryRow = { resolved_account?: string | null; correlation_id?: string | null; model_pinned?: number | null; + session_tag?: string | null; }; const RESOLVED_ACCOUNT_SQL = "COALESCE(NULLIF(pc.name, ''), NULLIF(pc.email, ''), cl.account)"; @@ -535,6 +536,7 @@ function mapSummaryRow(row: CallLogSummaryRow) { hasPipelineDetails: toNumber(row.has_pipeline_details) === 1, correlationId: row.correlation_id || null, modelPinned: toNumber(row.model_pinned) === 1, + sessionTag: row.session_tag || null, }; } @@ -624,6 +626,7 @@ export async function saveCallLog(entry: any) { toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId), correlationId: entry.correlationId || null, modelPinned: entry.modelPinned ? 1 : 0, + sessionTag: entry.sessionTag || null, }; const requestSummary = noLogEnabled @@ -672,7 +675,7 @@ export async function saveCallLog(entry: any) { combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, has_request_body, has_response_body, has_pipeline_details, request_summary, - correlation_id, model_pinned + correlation_id, model_pinned, session_tag ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @@ -683,7 +686,7 @@ export async function saveCallLog(entry: any) { @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary, - @correlationId, @modelPinned + @correlationId, @modelPinned, @sessionTag ) ` ).run({ @@ -757,6 +760,22 @@ if (shouldPersistToDisk && process.env.NODE_ENV !== "test") { scheduleCallLogRotation(); } +/** + * Pushes a `column LIKE %value%` condition (mirrors the correlationId/sessionTag substring-match + * precedent). Extracted so getCallLogs stays under the max-lines-per-function ratchet — #8249. + */ +function pushLikeFilter( + conditions: string[], + params: Record, + column: string, + paramKey: string, + value: unknown +) { + if (!value) return; + conditions.push(`cl.${column} LIKE @${paramKey}`); + params[paramKey] = `%${value}%`; +} + export async function getCallLogs(filter: any = {}) { const db = getDbInstance(); let sql = ` @@ -800,10 +819,8 @@ export async function getCallLogs(filter: any = {}) { conditions.push("(cl.api_key_name LIKE @apiKeyQ OR cl.api_key_id LIKE @apiKeyQ)"); params.apiKeyQ = `%${filter.apiKey}%`; } - if (filter.correlationId) { - conditions.push("cl.correlation_id LIKE @correlationId"); - params.correlationId = `%${filter.correlationId}%`; - } + pushLikeFilter(conditions, params, "correlation_id", "correlationId", filter.correlationId); + pushLikeFilter(conditions, params, "session_tag", "sessionTag", filter.sessionTag); if (filter.combo) { conditions.push("cl.combo_name IS NOT NULL"); } diff --git a/tests/unit/call-logs-session-tag.test.ts b/tests/unit/call-logs-session-tag.test.ts new file mode 100644 index 0000000000..4e466af1f2 --- /dev/null +++ b/tests/unit/call-logs-session-tag.test.ts @@ -0,0 +1,132 @@ +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"; + +// #8249: caller session tag (X-OmniRoute-Session-Id header) propagated into call_logs +// so operators can attribute cost per caller session. Isolated DATA_DIR per PII learnings §3 +// (resetDbInstance() + handle cleanup in test.after so the node:test runner doesn't hang). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-session-tag-")); +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"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("saveCallLog persists sessionTag when explicitly supplied", async () => { + const testId = `test-sessiontag-${Date.now()}`; + + await callLogs.saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "sess-abc", + }); + + const db = core.getDbInstance(); + const row = db + .prepare("SELECT id, session_tag FROM call_logs WHERE id = ?") + .get(testId) as Record; + assert.ok(row, "row should exist in call_logs"); + assert.equal(row.session_tag, "sess-abc"); +}); + +test("saveCallLog stores NULL session_tag when absent (never synthesized)", async () => { + const testId = `test-nosessiontag-${Date.now()}`; + + await callLogs.saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); + + const db = core.getDbInstance(); + const row = db + .prepare("SELECT id, session_tag FROM call_logs WHERE id = ?") + .get(testId) as Record; + assert.ok(row, "row should exist in call_logs"); + assert.equal(row.session_tag, null, "session_tag must be null when no header was supplied"); +}); + +test("getCallLogs returns sessionTag on the mapped row", async () => { + const testId = `test-getsessiontag-${Date.now()}`; + + await callLogs.saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "sess-roundtrip", + }); + + const logs = await callLogs.getCallLogs({ limit: 200 }); + const found = logs.find((l: { id: string }) => l.id === testId); + assert.ok(found, "log entry should be found via getCallLogs"); + assert.equal(found.sessionTag, "sess-roundtrip"); +}); + +test("getCallLogs filters by sessionTag (substring match, mirroring correlationId)", async () => { + const idMatch = `test-filter-match-${Date.now()}`; + const idOther = `test-filter-other-${Date.now()}`; + + await callLogs.saveCallLog({ + id: idMatch, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "customer-42-session", + }); + await callLogs.saveCallLog({ + id: idOther, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "unrelated-session", + }); + + const results = await callLogs.getCallLogs({ sessionTag: "customer-42" }); + const ids = results.map((r: { id: string }) => r.id); + assert.ok(ids.includes(idMatch), "matching sessionTag row must be returned"); + assert.ok(!ids.includes(idOther), "non-matching sessionTag row must be excluded"); +}); + +test("schemaColumns self-heal ALTER for session_tag is idempotent", async () => { + const db = core.getDbInstance(); + // Re-run the exact idempotent guard twice; must not throw either time. + const { ensureCallLogsColumns } = await import("../../src/lib/db/schemaColumns.ts"); + assert.doesNotThrow(() => ensureCallLogsColumns(db)); + assert.doesNotThrow(() => ensureCallLogsColumns(db)); + + const columns = db.prepare("PRAGMA table_info(call_logs)").all() as Array<{ name: string }>; + assert.ok( + columns.some((c) => c.name === "session_tag"), + "call_logs.session_tag column must exist" + ); +}); From 53a91b3df83295b221fde86aa63c364549962324 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 25 Jul 2026 02:50:46 -0300 Subject: [PATCH 11/83] feat(api): quota-aware fallback routing for web-fetch providers (#8297) (#8335) Mirror the search/route.ts pattern for /v1/web/fetch: skip rate-limited stubs instead of letting them short-circuit auto-select, walk the fixed-priority pool (fill-first) with a request-time fallback on retryable/quota upstream statuses (429 always; 402/403 for Firecrawl/Tavily/TinyFish quota-style tiers), and return a proper 429 (with Retry-After) when the whole pool is exhausted instead of a generic 400. Explicit-provider requests never silently fall back. --- .../8297-web-fetch-quota-aware-fallback.md | 1 + docs/reference/API_REFERENCE.md | 26 ++ src/app/api/v1/web/fetch/route.ts | 255 +++++++++++++--- tests/unit/web-fetch-quota-fallback.test.ts | 274 ++++++++++++++++++ 4 files changed, 511 insertions(+), 45 deletions(-) create mode 100644 changelog.d/features/8297-web-fetch-quota-aware-fallback.md create mode 100644 tests/unit/web-fetch-quota-fallback.test.ts diff --git a/changelog.d/features/8297-web-fetch-quota-aware-fallback.md b/changelog.d/features/8297-web-fetch-quota-aware-fallback.md new file mode 100644 index 0000000000..c916cf1767 --- /dev/null +++ b/changelog.d/features/8297-web-fetch-quota-aware-fallback.md @@ -0,0 +1 @@ +- feat(api): quota-aware fallback routing for web-fetch providers (#8297) diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 32aab3fbe8..fbf41ea286 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -344,6 +344,32 @@ Web/search provider abstraction (Tavily, Brave, Exa, Serper, etc.). --- +## Web Fetch API + +Extract content from a URL via a configured web-fetch provider (Firecrawl, Jina +Reader, Tavily Extract, TinyFish Fetch). + +| Method | Path | Description | +| ------ | -------------- | ------------------------------------------------------------------------- | +| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` | + +**Auth:** Bearer API key (`extractApiKey` + `isValidApiKey`). Policy enforced via `enforceApiKeyPolicy`. + +**Quota-aware fallback (#8297):** when no explicit `provider` is given, the pool +(`firecrawl` → `jina-reader` → `tavily-search` → `tinyfish`) is walked in fixed +priority order (fill-first) — a rate-limited-but-configured provider is skipped +instead of short-circuiting the request, and a retryable/quota upstream failure +(HTTP 429 always; 402/403 for Firecrawl/Tavily/TinyFish quota-style free tiers — +not for Jina Reader, and never for a plain 400 bad request) falls through to the +next untried credentialed provider at request time. When every provider in the +pool is exhausted, the endpoint returns a single `429` (with a `Retry-After` +header) instead of the previous generic `400`. When an explicit `provider` is +requested, there is **no** silent fallback — a rate-limited or failing explicit +provider surfaces its own error (`429` if rate-limited, otherwise the upstream +status). + +--- + ## WebSocket Streaming ```bash diff --git a/src/app/api/v1/web/fetch/route.ts b/src/app/api/v1/web/fetch/route.ts index 1fbfa0be5a..f7c49f8da1 100644 --- a/src/app/api/v1/web/fetch/route.ts +++ b/src/app/api/v1/web/fetch/route.ts @@ -6,11 +6,21 @@ * * Request: { url, provider?, format?, depth?, wait_for_selector?, include_metadata? } * Response: { provider, url, content, links, metadata, screenshot_url } + * + * Quota-aware fallback (#8297): when no explicit provider is requested, the + * pool is walked in fixed priority order (fill-first) — a rate-limited or + * quota-exhausted provider is skipped instead of short-circuiting the whole + * request. When an explicit provider is requested, no silent fallback is + * performed — a rate-limited/failing explicit provider surfaces its own error. */ -import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; -import { handleWebFetch } from "@omniroute/open-sse/handlers/webFetch.ts"; +import { + handleWebFetch, + type WebFetchCredentials, + type WebFetchResult, +} from "@omniroute/open-sse/handlers/webFetch.ts"; import * as log from "@/sse/utils/logger"; import { extractApiKey, @@ -21,6 +31,11 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; import { v1WebFetchSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + isAllRateLimitedCredentials, + rateLimitedProviderResponse, + type RateLimitedCredentials, +} from "@/app/api/v1/_shared/rateLimit"; const CORS_HEADERS = { "Access-Control-Allow-Methods": "POST, OPTIONS", @@ -30,25 +45,192 @@ const CORS_HEADERS = { const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search", "tinyfish"] as const; type WebFetchProviderId = (typeof WEB_FETCH_PROVIDERS)[number]; +// Providers whose free/low tiers surface quota exhaustion as 402/403 instead +// of (or in addition to) 429. jina-reader has no such quota-status signal — +// a 402/403 there is a real auth/bad-request failure, not exhaustion. +const QUOTA_STATUS_PROVIDERS = new Set([ + "firecrawl", + "tavily-search", + "tinyfish", +]); + +type CredentialsLookup = WebFetchCredentials | RateLimitedCredentials | null; + export async function OPTIONS() { return new Response(null, { headers: CORS_HEADERS }); } /** - * Resolve credentials for a web-fetch provider. Tries each known provider in - * priority order when no explicit provider is requested. + * Resolve credentials for a web-fetch provider (may be a rate-limited stub, + * real credentials, or null when unconfigured). */ -async function resolveCredentials( - providerId: WebFetchProviderId -): Promise<{ apiKey?: string } | null> { +async function resolveCredentials(providerId: WebFetchProviderId): Promise { try { const creds = await getProviderCredentialsWithQuotaPreflight(providerId); - return creds ?? null; + return (creds as CredentialsLookup) ?? null; } catch { return null; } } +/** A request-time upstream status that means "try the next provider" instead of giving up. */ +function isRetryableWebFetchStatus(providerId: WebFetchProviderId, status?: number): boolean { + if (status === HTTP_STATUS.RATE_LIMITED) return true; + if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) { + return QUOTA_STATUS_PROVIDERS.has(providerId); + } + return false; +} + +/** Find the next untried, non-rate-limited, credentialed provider in pool order. */ +async function findNextFallbackProvider( + tried: Set +): Promise<{ providerId: WebFetchProviderId; credentials: WebFetchCredentials } | null> { + for (const pid of WEB_FETCH_PROVIDERS) { + if (tried.has(pid)) continue; + const creds = await resolveCredentials(pid); + tried.add(pid); + if (creds && !isAllRateLimitedCredentials(creds)) { + return { providerId: pid, credentials: creds }; + } + } + return null; +} + +interface WebFetchExecutionInput { + url: string; + format: "markdown" | "html" | "links" | "screenshot"; + depth: 0 | 1 | 2; + wait_for_selector?: string; + include_metadata?: boolean; +} + +interface WebFetchExecutionResult { + result: WebFetchResult; + provider: WebFetchProviderId; + poolExhausted: boolean; +} + +/** + * Execute the web-fetch request. When `allowFallback` is true (auto-select), + * a retryable/quota upstream failure walks the remaining pool in order + * before giving up. Explicit-provider requests never fall back. + */ +async function executeWithFallback( + reqBody: WebFetchExecutionInput, + startProvider: WebFetchProviderId, + startCredentials: WebFetchCredentials, + allowFallback: boolean, + triedProviders: Set +): Promise { + let provider = startProvider; + let credentials = startCredentials; + let result = await handleWebFetch(reqBody, credentials, provider); + + if (!allowFallback) { + return { result, provider, poolExhausted: false }; + } + + while (!result.success && isRetryableWebFetchStatus(provider, result.status)) { + const next = await findNextFallbackProvider(triedProviders); + if (!next) { + return { result, provider, poolExhausted: true }; + } + provider = next.providerId; + credentials = next.credentials; + result = await handleWebFetch(reqBody, credentials, provider); + } + + return { result, provider, poolExhausted: false }; +} + +type ResolvedWebFetchTarget = + | { + ok: true; + provider: WebFetchProviderId; + credentials: WebFetchCredentials; + tried: Set; + isExplicit: boolean; + } + | { ok: false; response: Response }; + +/** Resolve credentials for an explicitly requested provider (no fallback allowed). */ +async function resolveExplicitTarget( + providerId: WebFetchProviderId +): Promise { + const creds = await resolveCredentials(providerId); + if (isAllRateLimitedCredentials(creds)) { + return { ok: false, response: rateLimitedProviderResponse(providerId, creds) }; + } + if (!creds) { + return { + ok: false, + response: errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for web-fetch provider: ${providerId}. ` + + `Add an API key for "${providerId}" in the dashboard.` + ), + }; + } + return { + ok: true, + provider: providerId, + credentials: creds, + tried: new Set([providerId]), + isExplicit: true, + }; +} + +/** + * Auto-select: walk the pool in fixed priority order (fill-first), skipping + * rate-limited stubs instead of letting them short-circuit the loop (#8297). + */ +async function resolveAutoSelectTarget(): Promise { + let firstRateLimited: { + providerId: WebFetchProviderId; + credentials: RateLimitedCredentials; + } | null = null; + + for (const pid of WEB_FETCH_PROVIDERS) { + const creds = await resolveCredentials(pid); + if (isAllRateLimitedCredentials(creds)) { + firstRateLimited ??= { providerId: pid, credentials: creds }; + continue; + } + if (creds) { + return { ok: true, provider: pid, credentials: creds, tried: new Set([pid]), isExplicit: false }; + } + } + + if (firstRateLimited) { + return { + ok: false, + response: rateLimitedProviderResponse( + firstRateLimited.providerId, + firstRateLimited.credentials + ), + }; + } + return { + ok: false, + response: errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for any web-fetch provider. ` + + `Add an API key for one of: ${WEB_FETCH_PROVIDERS.join(", ")}.` + ), + }; +} + +/** Resolve the provider + credentials to use for this request (explicit or auto-select). */ +async function resolveWebFetchTarget( + requestedProvider: string | undefined +): Promise { + if (requestedProvider) { + return resolveExplicitTarget(requestedProvider as WebFetchProviderId); + } + return resolveAutoSelectTarget(); +} + export async function POST(request: Request) { let rawBody: unknown; try { @@ -79,43 +261,13 @@ export async function POST(request: Request) { const policy = await enforceApiKeyPolicy(request, "web-fetch"); if (policy.rejection) return policy.rejection; - // Resolve provider + credentials - let resolvedProvider: WebFetchProviderId | undefined; - let credentials: { apiKey?: string } = {}; + // Resolve provider + credentials (explicit provider never falls back; #8297) + const target = await resolveWebFetchTarget(body.provider); + if (!target.ok) return target.response; - if (body.provider) { - resolvedProvider = body.provider as WebFetchProviderId; - const creds = await resolveCredentials(resolvedProvider); - if (!creds) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `No credentials configured for web-fetch provider: ${resolvedProvider}. ` + - `Add an API key for "${resolvedProvider}" in the dashboard.` - ); - } - credentials = creds; - } else { - // Auto-select: try providers in priority order - for (const pid of WEB_FETCH_PROVIDERS) { - const creds = await resolveCredentials(pid); - if (creds) { - resolvedProvider = pid; - credentials = creds; - break; - } - } - if (!resolvedProvider) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `No credentials configured for any web-fetch provider. ` + - `Add an API key for one of: ${WEB_FETCH_PROVIDERS.join(", ")}.` - ); - } - } + log.info("WEB_FETCH", `${target.provider} | ${body.url} | format=${body.format}`); - log.info("WEB_FETCH", `${resolvedProvider} | ${body.url} | format=${body.format}`); - - const result = await handleWebFetch( + const { result, provider: finalProvider, poolExhausted } = await executeWithFallback( { url: body.url, format: body.format, @@ -123,10 +275,19 @@ export async function POST(request: Request) { wait_for_selector: body.wait_for_selector, include_metadata: body.include_metadata, }, - credentials, - resolvedProvider + target.provider, + target.credentials, + !target.isExplicit, + target.tried ); + if (poolExhausted) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + "All configured web-fetch providers are rate limited or quota-exhausted" + ); + } + if (!result.success) { return new Response( JSON.stringify({ @@ -139,6 +300,10 @@ export async function POST(request: Request) { ); } + if (finalProvider !== target.provider) { + log.info("WEB_FETCH", `Fell back from ${target.provider} to ${finalProvider}`); + } + return new Response(JSON.stringify(result.data), { status: 200, headers: { "Content-Type": "application/json", ...CORS_HEADERS }, diff --git a/tests/unit/web-fetch-quota-fallback.test.ts b/tests/unit/web-fetch-quota-fallback.test.ts new file mode 100644 index 0000000000..4c7a77f595 --- /dev/null +++ b/tests/unit/web-fetch-quota-fallback.test.ts @@ -0,0 +1,274 @@ +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-web-fetch-fallback-")); +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 webFetchRoute = await import("../../src/app/api/v1/web/fetch/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection( + provider: string, + overrides: { + apiKey?: string | null; + rateLimitedUntil?: string | null; + } = {} +) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: overrides.apiKey ?? "test-key", + isActive: true, + testStatus: "active", + rateLimitedUntil: overrides.rateLimitedUntil ?? null, + providerSpecificData: {}, + }); +} + +function postWebFetch(body: Record) { + return webFetchRoute.POST( + new Request("http://localhost/api/v1/web/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com", ...body }), + }) + ); +} + +interface WebFetchTestBody { + provider?: string; + content?: string; + error?: { message: string }; +} + +async function readJson(response: Response): Promise { + return (await response.json()) as WebFetchTestBody; +} + +const FUTURE_ISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── (a) credential-time: rate-limited stub is skipped, not short-circuited ── + +test("auto-select skips a rate-limited firecrawl and falls to jina-reader", async () => { + await seedConnection("firecrawl", { rateLimitedUntil: FUTURE_ISO }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + throw new Error("firecrawl should never be called once rate-limited"); + } + if (u.includes("r.jina.ai")) { + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 200); + assert.equal(body.provider, "jina-reader"); + assert.equal(body.content, "jina content"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (b) request-time: credentialed provider returns 429 → falls through ──── + +test("auto-select falls through to jina-reader when firecrawl returns 429 at request time", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + return new Response(JSON.stringify({ error: "rate limited" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + } + if (u.includes("r.jina.ai")) { + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 200); + assert.equal(body.provider, "jina-reader"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (c) provider-specific quota status (402/403) triggers fallback; plain 400 does NOT ── + +test("auto-select falls through to jina-reader when firecrawl returns 403 (quota-style)", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + return new Response(JSON.stringify({ error: "quota exceeded" }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + } + if (u.includes("r.jina.ai")) { + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 200); + assert.equal(body.provider, "jina-reader"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("auto-select does NOT fall through when firecrawl returns a plain 400 bad request", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + let jinaWasCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + return new Response(JSON.stringify({ error: "bad url" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + if (u.includes("r.jina.ai")) { + jinaWasCalled = true; + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 400); + assert.equal(jinaWasCalled, false, "jina-reader must not be tried for a non-quota 400"); + assert.ok(!(body.error?.message ?? "").includes("at /"), "error must not leak stack paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (d) explicit rate-limited provider → 429, no silent fallback ─────────── + +test("explicit rate-limited provider request returns 429 without falling back", async () => { + await seedConnection("firecrawl", { rateLimitedUntil: FUTURE_ISO }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + let jinaWasCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("r.jina.ai")) { + jinaWasCalled = true; + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({ provider: "firecrawl" }); + const body = await readJson(response); + + assert.equal(response.status, 429); + assert.equal(jinaWasCalled, false, "explicit provider request must never fall back"); + assert.ok(response.headers.get("Retry-After"), "should include a Retry-After header"); + assert.ok(!(body.error?.message ?? "").includes("at /"), "error must not leak stack paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (e) whole pool exhausted at request time → single 429 with retry-after ─ + +test("auto-select returns a single 429 with retry-after when the whole pool is exhausted", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + await seedConnection("tavily-search", { apiKey: "tavily-key" }); + await seedConnection("tinyfish", { apiKey: "tf-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return new Response(JSON.stringify({ error: "rate limited" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 429); + assert.ok(response.headers.get("Retry-After"), "should include a Retry-After header"); + assert.ok(!(body.error?.message ?? "").includes("at /"), "error must not leak stack paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── No credentials at all → generic 400 (unchanged behavior) ────────────── + +test("auto-select returns 400 when no web-fetch provider is configured", async () => { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 400); + assert.ok((body.error?.message ?? "").includes("No credentials configured")); +}); From 9a0e764459f20ec949d2189408c048949d7100b1 Mon Sep 17 00:00:00 2001 From: Adrian Rogala Date: Sat, 25 Jul 2026 07:50:53 +0200 Subject: [PATCH 12/83] feat(cli): replace ANTHROPIC_SMALL_FAST_MODEL with Fable default (#8343) Claude Code retired ANTHROPIC_SMALL_FAST_MODEL; expose ANTHROPIC_DEFAULT_FABLE_MODEL from the claude registry instead. --- open-sse/config/providerRegistry.ts | 4 +++- src/shared/constants/cliTools.ts | 13 +++++++------ tests/unit/claude-cli-defaults.test.ts | 4 ++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index adf16477d0..70f6ce831c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -258,11 +258,12 @@ export function getProviderCategory(provider: string): "oauth" | "apikey" { } /** - * Derive the latest opus/sonnet/haiku model IDs from the `claude` registry entry. + * Derive the latest fable/opus/sonnet/haiku model IDs from the `claude` registry entry. * Picks the first model whose ID matches each family pattern — registry order * determines precedence, so newer models should be listed first. */ export function getClaudeCodeDefaultModels(): { + fable: string; opus: string; sonnet: string; haiku: string; @@ -270,6 +271,7 @@ export function getClaudeCodeDefaultModels(): { const models = REGISTRY.claude?.models ?? []; const find = (pattern: RegExp) => models.find((m) => pattern.test(m.id))?.id ?? ""; return { + fable: find(/fable/i), opus: find(/opus/i), sonnet: find(/sonnet/i), haiku: find(/haiku/i), diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index f2b58cd528..41bed13270 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -23,11 +23,12 @@ export const CLI_TOOLS: Record = { envVars: { baseUrl: "ANTHROPIC_BASE_URL", model: "ANTHROPIC_MODEL", + fableModel: "ANTHROPIC_DEFAULT_FABLE_MODEL", opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL", sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL", haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL", }, - modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"], + modelAliases: ["default", "fable", "sonnet", "opus", "haiku", "opusplan"], settingsFile: "~/.claude/settings.json", defaultCommand: "claude", defaultModels: [ @@ -40,11 +41,11 @@ export const CLI_TOOLS: Record = { isTopLevel: true, }, { - id: "smallFast", - name: "Small Fast Model", - alias: "smallFast", - envKey: "ANTHROPIC_SMALL_FAST_MODEL", - defaultValue: _cc.haiku ? `cc/${_cc.haiku}` : "cc/claude-haiku-4-5-20251001", + id: "fable", + name: "Claude Fable", + alias: "fable", + envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", + defaultValue: _cc.fable ? `cc/${_cc.fable}` : "cc/claude-fable-5", isTopLevel: true, }, { diff --git a/tests/unit/claude-cli-defaults.test.ts b/tests/unit/claude-cli-defaults.test.ts index 552b69b712..244fa715a3 100644 --- a/tests/unit/claude-cli-defaults.test.ts +++ b/tests/unit/claude-cli-defaults.test.ts @@ -6,11 +6,15 @@ test("getClaudeCodeDefaultModels returns expected default models", () => { const models = getClaudeCodeDefaultModels(); // They should be non-empty strings because providerRegistry is populated statically + assert.ok(typeof models.fable === "string"); assert.ok(typeof models.opus === "string"); assert.ok(typeof models.sonnet === "string"); assert.ok(typeof models.haiku === "string"); // Check that the returned IDs match the expected patterns + if (models.fable) { + assert.match(models.fable, /fable/i); + } if (models.opus) { assert.match(models.opus, /opus/i); } From 58ab8b1d2c24370ac37667589384da3ad5251659 Mon Sep 17 00:00:00 2001 From: Jay Ongg <11032569+swingtempo@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:51:00 -0700 Subject: [PATCH 13/83] Clicking a provider card hitting back loses scroll position (#8349) * clicking a provider card hitting back loses scroll position * clean up code * Rename some funcitons * minor UX updates * add null-guard in highlight() * Add providerCardHandle tests * add highlight tests. refactored code into separate utility functions * Minor change to skip a firstElementChild call - use a ref to access the Link inside ProviderCard. --- .../providers/components/ProviderCard.tsx | 70 +++++++-- .../(dashboard)/dashboard/providers/page.tsx | 72 ++++++++++ .../providers/providerPageHighlightUtils.ts | 27 ++++ tests/unit/ui/providerCardHandle.test.tsx | 134 ++++++++++++++++++ .../ui/providerPageHighlightLogic.test.tsx | 129 +++++++++++++++++ 5 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts create mode 100644 tests/unit/ui/providerCardHandle.test.tsx create mode 100644 tests/unit/ui/providerPageHighlightLogic.test.tsx diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index fc8c0423b8..c1382ccf58 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -1,7 +1,7 @@ "use client"; import type { MouseEvent, ReactNode } from "react"; -import { useState } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -74,6 +74,7 @@ interface ProviderCardProps { stats: ProviderStats; authType?: string; onToggle: (active: boolean) => void; + onCardClick?: (id: string) => void; } const DOT_COLORS: Record = { @@ -152,17 +153,51 @@ function getStatusDisplay( return parts; } -export default function ProviderCard({ - providerId, - provider, - stats, - authType = "apikey", - onToggle, -}: ProviderCardProps) { +export type ProviderCardHandle = { + highlight: () => void; + getProviderId(): string; + scrollIntoView: (options?: ScrollIntoViewOptions) => void; +}; + +const ProviderCard = forwardRef(function ProviderCard( + { providerId, provider, stats, authType = "apikey", onToggle, onCardClick }, + ref +) { const t = useTranslations("providers"); const tc = useTranslations("common"); const tp = useTranslations("miniPlayground"); const [testExpanded, setTestExpanded] = useState(false); + const innerRef = useRef(null); + const linkElementRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + getProviderId() { + return providerId; + }, + highlight() { + const el = innerRef.current; + if (!el) return; + linkElementRef.current?.focus(); + const surface = linkElementRef.current?.firstElementChild; + surface?.animate( + [ + { backgroundColor: "rgba(59,130,246,0.22)" }, + { backgroundColor: "rgba(59,130,246,0.08)" }, + { backgroundColor: "transparent" }, + ], + { duration: 3000, easing: "ease-in-out" } + ); + }, + scrollIntoView() { + const el = innerRef.current; + if (!el) return; + el.scrollIntoView({ behavior: "auto", block: "center" }); + }, + }), + [providerId, innerRef, linkElementRef] + ); // Show the Test button for LLM providers (when serviceKinds includes "llm" // OR when the provider has no explicit serviceKinds but is a regular LLM provider @@ -260,9 +295,18 @@ export default function ProviderCard({ onToggle(allDisabled); }; + const handleCardClick = useCallback(() => { + onCardClick?.(providerId); + }, [onCardClick, providerId]); + return ( -
- +
+ {}} + onChange={undefined} title={allDisabled ? t("enableProvider") : t("disableProvider")} />
@@ -461,4 +505,6 @@ export default function ProviderCard({ )}
); -} +}); + +export default ProviderCard; diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 678356b384..52280aef21 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -34,6 +34,7 @@ import { loadProviderPageData, } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; +import { recordProviderNavigation, resolveHighlightedCard } from "./providerPageHighlightUtils"; import { readProviderDisplayModePreference, shouldSyncProviderDisplayMode, @@ -50,6 +51,7 @@ import { CategoryDot } from "./components/CategoryDot"; import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal"; import NoAuthProvidersSection from "./components/NoAuthProvidersSection"; import ProviderCard from "./components/ProviderCard"; +import type { ProviderCardHandle } from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; import ProviderSummaryCard from "./components/ProviderSummaryCard"; import { @@ -205,6 +207,22 @@ export default function ProvidersPage() { // #4240: media-category (serviceKind) filter — composes with activeCategory, // search and configured-only. null = no serviceKind filter. const [activeServiceKind, setActiveServiceKind] = useState(null); + + // a highlighted card is one that we should scroll into view and highlight upon navigation + const [highlightedProviderId, setHighlightedProviderId] = useState(() => { + return history.state?.providerId ?? null; + }); + + const handleCardClick = useCallback((id: string) => { + recordProviderNavigation(id); + }, []); + + const highlightedCardRef = useCallback( + (handle: ProviderCardHandle | null) => { + resolveHighlightedCard(handle, highlightedProviderId, () => setHighlightedProviderId(null)); + }, + [highlightedProviderId, setHighlightedProviderId] + ); const notify = useNotificationStore(); const sectionCategoryAliases: Record = { cloud: "cloudagent", @@ -918,6 +936,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(entry.providerId, entry.toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1004,6 +1025,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1078,6 +1102,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1136,6 +1163,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1184,6 +1214,9 @@ export default function ProvidersPage() { stats={stats} authType="web-cookie" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1232,6 +1265,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1285,6 +1321,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1351,6 +1390,9 @@ export default function ProvidersPage() { stats={stats} authType="upstream-proxy" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1383,6 +1425,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1416,6 +1461,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1449,6 +1497,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1499,6 +1550,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1546,6 +1600,9 @@ export default function ProvidersPage() { stats={stats} authType="local" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1592,6 +1649,9 @@ export default function ProvidersPage() { stats={stats} authType="search" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1624,6 +1684,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1657,6 +1720,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1704,6 +1770,9 @@ export default function ProvidersPage() { stats={stats} authType="audio" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1736,6 +1805,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts new file mode 100644 index 0000000000..d5d7976340 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts @@ -0,0 +1,27 @@ +import type { ProviderCardHandle } from "./components/ProviderCard"; + +/** + * Called before navigating to a provider detail page. Persists the provider + * id in history.state so the list page can scroll to it on back-navigation. + */ +export function recordProviderNavigation(id: string) { + window.history.replaceState({ providerId: id }, ""); +} + +/** + * Ref callback for ProviderCard. When the rendered card's provider id + * matches the highlighted id, scrolls it into view and triggers the + * highlight animation. Always clears the highlighted id afterward so + * subsequent re-renders don't re-scroll. + */ +export function resolveHighlightedCard( + handle: ProviderCardHandle | null, + highlightedProviderId: string | null, + onAfterHighlight: () => void +) { + if (handle?.getProviderId() === highlightedProviderId) { + handle.scrollIntoView({ behavior: "auto", block: "center" }); + handle.highlight(); + } + onAfterHighlight(); +} diff --git a/tests/unit/ui/providerCardHandle.test.tsx b/tests/unit/ui/providerCardHandle.test.tsx new file mode 100644 index 0000000000..ce8f4b7fd7 --- /dev/null +++ b/tests/unit/ui/providerCardHandle.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +/** + * ProviderCardHandle imperative API — highlight(), scrollIntoView(), getProviderId(). + * + * NOTE on placement: see providerCardKimiPartnerAccent.test.tsx for rationale + * on keeping tests in tests/unit/ui/ (both vitest configs discover it here). + */ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ProviderCard, { + type ProviderCardHandle, +} from "@/app/(dashboard)/dashboard/providers/components/ProviderCard"; + +vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); +vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); +vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); + +// jsdom does not implement scrollIntoView or animate +if (typeof Element.prototype.scrollIntoView === "undefined") { + Object.defineProperty(Element.prototype, "scrollIntoView", { + value: vi.fn(), + writable: true, + configurable: true, + }); +} +if (typeof Element.prototype.animate === "undefined") { + Object.defineProperty(Element.prototype, "animate", { + value: () => ({ cancel: () => {}, finished: Promise.resolve() }), + writable: true, + configurable: true, + }); +} + +describe("ProviderCardHandle imperative API", () => { + let container: HTMLDivElement | null = null; + let handle: ProviderCardHandle | null = null; + + afterEach(() => { + handle = null; + if (container) { + document.body.removeChild(container); + container = null; + } + }); + + const PROVIDER_ID = "openai"; + const PROVIDER_NAME = "OpenAI"; + + function renderAndCapture() { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + { + handle = h; + }} + providerId={PROVIDER_ID} + provider={{ id: PROVIDER_ID, name: PROVIDER_NAME }} + stats={{ total: 1, connected: 1, error: 0, warning: 0 }} + authType="apikey" + onToggle={() => {}} + /> + ); + }); + } + + it("getProviderId returns the providerId passed as a prop", () => { + renderAndCapture(); + expect(handle).not.toBeNull(); + expect(handle!.getProviderId()).toBe(PROVIDER_ID); + }); + + it("scrollIntoView calls Element.scrollIntoView on the wrapper div", () => { + renderAndCapture(); + const spy = vi.spyOn(Element.prototype, "scrollIntoView"); + act(() => { + handle!.scrollIntoView(); + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + spy.mockRestore(); + }); + + it("highlight calls animate on the Card surface", () => { + renderAndCapture(); + const spy = vi.spyOn(Element.prototype, "animate"); + act(() => { + handle!.highlight(); + }); + expect(spy).toHaveBeenCalledTimes(1); + const keyframes = spy.mock.calls[0][0] as Keyframe[]; + expect(keyframes).toHaveLength(3); + expect((keyframes[0] as Record).backgroundColor).toBe("rgba(59,130,246,0.22)"); + expect((keyframes[2] as Record).backgroundColor).toBe("transparent"); + spy.mockRestore(); + }); + + it("highlight focuses the link element", () => { + renderAndCapture(); + const focusSpy = vi.fn(); + const link = container!.querySelector("a"); + if (link) link.focus = focusSpy; + act(() => { + handle!.highlight(); + }); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it("getProviderId returns the correct id for a different provider", () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let h: ProviderCardHandle | null = null; + act(() => { + root.render( + { + h = n; + }} + providerId="kimi-coding" + provider={{ id: "kimi-coding", name: "Kimi Code CLI" }} + stats={{ total: 0, connected: 0, error: 0, warning: 0 }} + authType="oauth" + onToggle={() => {}} + /> + ); + }); + expect(h!.getProviderId()).toBe("kimi-coding"); + }); +}); diff --git a/tests/unit/ui/providerPageHighlightLogic.test.tsx b/tests/unit/ui/providerPageHighlightLogic.test.tsx new file mode 100644 index 0000000000..7052474a84 --- /dev/null +++ b/tests/unit/ui/providerPageHighlightLogic.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +/** + * Tests for the page-level highlighted-card matching/clearing logic + * extracted into providerPageHighlightUtils.ts. + * + * These import the real production functions — not copied code. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProviderCardHandle } from "@/app/(dashboard)/dashboard/providers/components/ProviderCard"; +import { + recordProviderNavigation, + resolveHighlightedCard, +} from "@/app/(dashboard)/dashboard/providers/providerPageHighlightUtils"; + +function createMockHandle( + id: string, + callbacks?: { onScroll?(): void; onHighlight?(): void } +): ProviderCardHandle { + return { + getProviderId() { + return id; + }, + scrollIntoView() { + callbacks?.onScroll?.(); + }, + highlight() { + callbacks?.onHighlight?.(); + }, + }; +} + +describe("resolveHighlightedCard", () => { + it("calls scrollIntoView + highlight when handle matches highlighted id", () => { + const onScroll = vi.fn(); + const onHighlight = vi.fn(); + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("openai", { onScroll, onHighlight }); + + resolveHighlightedCard(handle, "openai", onAfterHighlight); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onHighlight).toHaveBeenCalledTimes(1); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("does NOT call scrollIntoView or highlight when ids do not match", () => { + const onScroll = vi.fn(); + const onHighlight = vi.fn(); + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("openai", { onScroll, onHighlight }); + + resolveHighlightedCard(handle, "anthropic", onAfterHighlight); + + expect(onScroll).not.toHaveBeenCalled(); + expect(onHighlight).not.toHaveBeenCalled(); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("calls onAfterHighlight even when handle is null", () => { + const onAfterHighlight = vi.fn(); + resolveHighlightedCard(null, "openai", onAfterHighlight); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("calls onAfterHighlight even when ids do not match", () => { + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("kimi-coding"); + resolveHighlightedCard(handle, "openai", onAfterHighlight); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("only the matching handle triggers scroll + highlight among multiple", () => { + const calls: string[] = []; + const onAfterHighlight = vi.fn(); + const handles = [ + createMockHandle("openai", { + onScroll: () => calls.push("openai-scroll"), + onHighlight: () => calls.push("openai-highlight"), + }), + createMockHandle("anthropic", { + onScroll: () => calls.push("anthropic-scroll"), + onHighlight: () => calls.push("anthropic-highlight"), + }), + createMockHandle("cursor", { + onScroll: () => calls.push("cursor-scroll"), + onHighlight: () => calls.push("cursor-highlight"), + }), + ]; + + resolveHighlightedCard(handles[0], "cursor", onAfterHighlight); + resolveHighlightedCard(handles[1], "cursor", onAfterHighlight); + resolveHighlightedCard(handles[2], "cursor", onAfterHighlight); + + expect(calls).toEqual(["cursor-scroll", "cursor-highlight"]); + expect(onAfterHighlight).toHaveBeenCalledTimes(3); + }); +}); + +describe("recordProviderNavigation", () => { + const originalReplaceState = window.history.replaceState; + + afterEach(() => { + window.history.replaceState = originalReplaceState; + }); + + it("calls history.replaceState with the provider id", () => { + const replaceSpy = vi.fn(); + window.history.replaceState = replaceSpy; + + recordProviderNavigation("openai"); + + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledWith({ providerId: "openai" }, ""); + }); + + it("sets different provider ids on successive calls", () => { + const replaceSpy = vi.fn(); + window.history.replaceState = replaceSpy; + + recordProviderNavigation("openai"); + recordProviderNavigation("anthropic"); + recordProviderNavigation("kimi-coding"); + + expect(replaceSpy).toHaveBeenCalledTimes(3); + expect(replaceSpy.mock.calls[0][0]).toEqual({ providerId: "openai" }); + expect(replaceSpy.mock.calls[1][0]).toEqual({ providerId: "anthropic" }); + expect(replaceSpy.mock.calls[2][0]).toEqual({ providerId: "kimi-coding" }); + }); +}); From 9dcbbd18c8d707218d1565a79bdc29a0490c6502 Mon Sep 17 00:00:00 2001 From: ikelvingo Date: Sat, 25 Jul 2026 13:51:07 +0800 Subject: [PATCH 14/83] fix(i18n): restore brand proper nouns and unify terminology in zh-CN and zh-TW (#8355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168) * fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179) * fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216) * fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (#7220) * fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (#7225) * test(ci): make the #6634 selfref guard hermetic — main's copy hard-fails every PR (#7341) main's copy of this test still does git I/O inside a unit test: const baseSrc = git(['show', 'origin/main:' + FILE]); Runners check out a shallow single ref, so origin/main does not resolve and the test dies with 'fatal: invalid object name origin/main'. Every PR into main fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336 and #7337, six PRs red on a defect none of them introduced. #7313 has no other red at all. release/v3.8.49 already carries a fix (2e42b8efc, #7174: try/catch, fetch origin/main on demand, t.skip() when unreachable), but it only reaches main at release time — so main stays broken for the whole cycle. Cherry-picking it would also import a new problem: PR Test Policy classifies t.skip() as a silenced assertion, which we watched it correctly catch on #7300 today. This is the hermetic version instead (ported from #7327, which does the same for the release branch): read the file straight off disk, compare against an empty base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point — and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the runner's checkout depth can break. The #6634 regression stays covered: the guard's logic lives in SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left byte-identical. Co-authored-by: growab * chore(quality): tighten main's coverage baseline to the CI's real numbers (#7347) main's ratchet had been failing --require-tighten on every PR: 11 metrics improved but the baseline was never tightened. Same class as the #6634 selfref guard — an infra fix that lands only on the release branch leaves main red for the whole cycle, and every PR into main pays for it. Values are the merged-coverage numbers from a run on main itself (a local run measures ~68% vs CI's ~80%; the baseline's own note warns about that gap). Only the 11 coverage values change — gitleaks and semgrepFindings keep main's own state. No changelog fragment: #7326 carries it on release/v3.8.49, and a second one here would double the entry at release time. * Add cliproxy provider exposure controls and manifest injection (#7329) * feat(fusion): let judge use its own knowledge and override the panel (#6804) The judge prompt said to write an answer 'grounded in that analysis', implicitly capping output at the panel's union. When all panel members miss or are collectively wrong on something, the judge should apply its own reasoning as a full participant and override consensus, while keeping an honesty guard against fabrication. Adds a regression test. Co-authored-by: Chirag Singhal * fix(api): raise provider apiKey cap for cookie-based web providers (#6715) (#6759) * fix(cli): fall back to settings.json when Claude Code binary is unresolvable (#6701) (#6734) getCliRuntimeStatus() only ever answered `installed` from binary resolution (known install paths + where/which PATH search), so a stale PATH, moved binary, or uncatalogued install method reported "not found" even when ~/.claude/settings.json proved the CLI was installed and used before — regressing behind upstream 9router's checkClaudeInstalled(), which already falls back to the settings file when where/which fails. withSettingsFallback() (new src/shared/services/cliInstallFallback.ts, kept out of the frozen cliRuntime.ts to respect its file-size ceiling) restores that parity: only when the binary lookup's own reason is "not_found" (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk. * fix(providers): honor explicit thinking.budget_tokens 0 in openai->gemini transform (#6813) (#6821) The transform forwarded the Claude-style thinking.budget_tokens into generationConfig.thinkingConfig.thinkingBudget, but the presence check was truthy (&& thinking.budget_tokens). An explicit budget_tokens: 0 — the natural way to disable thinking — is falsy, so it was dropped and the request fell through to the default thinkingConfig injection, making the model think despite an explicit request for zero. Use an explicit numeric check so 0 is honored as thinkingBudget 0; includeThoughts is only set for a non-zero budget. * fix(compression): reconcile outer vs per-engine token counts (#6488) (#6741) * fix(compression): reconcile outer vs per-engine token counts on degenerate output (#6488) Outer originalTokens/compressedTokens (real tiktoken counter over extracted message text) diverged from engineBreakdown[0]'s counts (a crude JSON.stringify(requestBody).length/4 estimate), worst on small/degenerate inputs where JSON structural overhead dominates. A single-engine breakdown entry represents the exact same before/after transformation as the overall response, so reconcileSingleEngineTokens() now overwrites that one entry's counts with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched. * chore(6741): resolve release sync — CHANGELOG.md restored to release tip, entry moved to changelog.d fragment (fragments-first) * fix(api): accept enableRenderers in RTK compression config schema (#6703) (#6757) * fix(db): break probe-failed/restore loop on large storage.sqlite (#6632) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * feat(cursor): add Opus 4.8, Fable 5, and Sonnet 5 model families (#6779) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's cursor registry + test changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(translator): read PDF/video file attachments for Gemini/Antigravity and Claude (#6790) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's translator + test changes. Co-authored-by: Wital Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(codex): strip include from compact responses requests (#6805) * fix(codex): strip include from compact responses requests Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(6805): move include-strip assertion to standalone test file to keep executor-codex.test.ts under frozen size cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(i18n): translate hardcoded Portuguese dashboard strings to English (#6769) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: Chirag Singhal Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(bootstrap): filter empty process.env values to prevent Docker env crash loop (#6828) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR) and the direct CHANGELOG.md edit (fragments-first); keeps only the author's bootstrap change. Co-authored-by: Andrian B. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(providers): update SenseNova Token Plan support (#6330) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); the author's constants/registry/snapshot deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(providers): classify 404 as MODEL_NOT_FOUND to stop retry storm (#6829) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR) and the direct CHANGELOG.md edit (fragments-first); the author's chatCore/errorClassifier deltas were re-applied cleanly onto the release tip. Co-authored-by: Andrian B. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(api): accept all catalog engines on compression PUT schema (#6792) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR). Resolved the release's OmniGlyph engine addition additively (types.ts/compression.ts kept both 'relevance' and 'omniglyph') and extended stackedPipelineStepSchema + STACKED_PIPELINE_ENGINE_INTENSITIES with the omniglyph branch so the ENGINE_CATALOG-parity test passes. Co-authored-by: Pitchfork-and-Torch Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(api): point CLI health command at /api/monitoring/health (#6677) (#6717) * fix(api): point CLI health command at /api/monitoring/health (#6677) bin/cli/commands/health.mjs called GET /api/health, a route that was moved to /api/monitoring/health without updating the CLI; the top-level /api/health handler never existed on disk (only degradation/ and ping/ sub-routes). Point runHealthCommand()/runHealthComponentsCommand() at /api/monitoring/health and read its real payload shape (activeConnections, circuitBreakers: {open,halfOpen,closed}, memoryUsage) instead of the old nonexistent requests/breakers/cache/memory fields. * chore(6717): re-sync onto release tip; move CHANGELOG entry to changelog.d fragment (fragments-first) * chore(cursor): add Grok 4.5 effort/fast model IDs (#6774) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED (#6791) * fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(deepseek): extract done-terminator helper to keep frozen file under cap Extracts the FINISHED-drain scheduler and finish-once guard added for the [DONE] terminator fix (#6777) into a new deepseek-web-done-terminator.ts module, so deepseek-web.ts stays under its frozen line cap (1148). Behavior is unchanged. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Pitchfork-and-Torch Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * feat(models): add capability override UI (#6727) * feat(models): add capability override UI Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); renumbered the migration 118 -> 119 to resolve the collision with 118_provider_param_filters.sql already on release/v3.8.47; the author's i18n/localDb deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(6727): import model-capability-overrides DB fns directly (not via localDb barrel) to keep localDb under file-size cap; aligns with anti-barrel convention * chore(db): satisfy known-symbols contract for modelCapabilityOverrides Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza * fix(cursor): use Agent CLI build id for x-cursor-client-version (#6795) * fix(cursor): use Agent CLI build id for x-cursor-client-version Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); the author's .env.example/docs deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): re-sync CHANGELOG.md to release tip (restore #6701 bullet) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(startup): rename reasoningControls.ts to avoid webpack casing collision (#6584) (#6718) * fix(startup): rename reasoningControls.ts to avoid webpack casing collision (#6584) * chore(6718): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(build): suppress Turbopack over-bundling warning from agentSkills generator (#6582) (#6720) * fix(build): suppress Turbopack over-bundling warning from agentSkills generator (#6582) generator.ts builds outputBase from a non-literal outputDir parameter, so Turbopack's file-tracing analyzer can't narrow it and emits an "Overly broad patterns" warning per entry point that imports the module (603 warnings on v3.8.46, up from 379). The fs access is legitimate and bounded, so next.config.mjs now suppresses this specific diagnostic via turbopack.ignoreIssue, mirroring the existing webpack.ignoreWarnings precedent in the same file. * chore(6720): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(providers): drop image_generation for Codex Spark models regardless of plan (#6651) (#6721) * fix(providers): drop image_generation for Codex Spark models regardless of plan (#6651) * chore(6721): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(providers): stop quota card re-sorting Codex/GLM bars by remaining % (#6687) (#6722) * fix(providers): stop quota card re-sorting Codex/GLM bars by remaining % (#6687) QuotaCardExpanded.tsx unconditionally re-sorted quotas by remaining percentage via sortQuotasByRemaining(), discarding the deterministic CODEX_QUOTA_ORDER/GLM_QUOTA_ORDER window order quotaParsing.ts's sortCodexOrder()/sortGlmOrder() had already established. A new hasFixedQuotaOrder() + resolveQuotaDisplayOrder() skip the re-sort for providers with a fixed window order (codex, glm family), threading providerId from QuotaCard.tsx through to the display layer. Regression guard: tests/unit/quota-card-expanded-fixed-order-6687.test.ts * chore(6722): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(startup): lazy-import ioredis in rateLimiter to fix MCP ERR_MODULE_NOT_FOUND (#6559) (#6725) * fix(startup): lazy-import ioredis in rateLimiter to fix MCP ERR_MODULE_NOT_FOUND (#6559) * chore(6725): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(resilience): resolve fp-pinned combo account back to real connection id (#6696) (#6732) * fix(resilience): resolve fp-pinned combo account back to real connection id (#6696) * chore(6732): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(api): Responses passthrough emits event-only SSE frames after filtering commentary output (#6561) (#6735) * fix(api): Responses passthrough emits event-only SSE frames after filtering commentary output (#6561) The #6199 commentary-drop `continue;` branches in stream.ts skipped the data: line for a dropped commentary event but never cleared the already-buffered event: line for the same frame, so the next blank line flushed the stale event: line alone -- an event-only SSE frame that crashes the OpenAI Python SDK's json.loads(). Both drop sites now call clearPendingPassthroughEvent() before continue. The commentary-drop decision was extracted into a new responsesCommentaryDrop.ts module so the fix does not grow the frozen stream.ts. * chore(6735): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(api): emit reasoning_content on claude-web + v0-vercel-web SSE (#6662) (#6743) * fix(api): emit reasoning_content on claude-web + v0-vercel-web /v1/chat/completions SSE (#6662) * chore(6743): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(sse): unwrap bare {function:{…}} tools in openai→claude translation (#6704) * fix(sse): unwrap bare {function:{…}} tools in openai→claude translation Some OpenAI-shape clients send a tool as a bare `{ function: {...} }` object, omitting the spec-required `type: "function"` parent wrapper. The tools-mapping in openai-to-claude.ts (~line 366) only unwrapped `tool.function` when `tool.type === "function"` was ALSO true, so a bare-function tool fell through to `toolData = tool` (the wrapper itself, with no `.name`), producing an empty `originalName` and silently dropping the tool from the translated request — worse than a 400, since the caller has no signal the tool never made it upstream. Unwrap `tool.function` whenever present, independent of the parent `type` field. Regression guard: tests/unit/openai-to-claude-bare-tool.test.ts. Co-authored-by: Samir Abis Inspired-by: https://github.com/decolua/9router/pull/2473 * chore(6704): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: Samir Abis * fix(oauth): avoid bare-email dedup of Codex OAuth logins (#6706) * fix(oauth): avoid bare-email dedup of Codex OAuth logins When an incoming Codex OAuth connection has no verifiable workspace/account id, do not merge it into an existing row on email match alone — that silently overwrote the other account's token pair. Require a matching chatgptUserId (a stable per-account JWT id) before merging; otherwise insert a distinct connection row. Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/2477 * chore(6706): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> * fix(sse): skip thinkingConfig for gemma models in openai→gemini translation (#6708) open-sse/translator/request/claude-to-gemini.ts already guards against sending thinkingConfig for gemma-4-* models (Gemma doesn't support it — Vertex returns 400: "Thinking budget is not supported for this model"), but the OpenAI-shape path (openai-to-gemini.ts) lacked the same guard, so OpenAI-shape clients hitting a vertex gemma-4-* model still got a 400. Mirrors the existing claude-to-gemini.ts guard: wrap the reasoning_effort and Claude-shape thinking.budget_tokens branches with a model.startsWith ("gemma-4") check. Branch 3 (default includeThoughts for modern Gemini models) already excludes non-"gemini" model ids and needed no change. Inspired-by: https://github.com/decolua/9router/pull/2480 Co-authored-by: chy1211 <31048289+chy1211@users.noreply.github.com> * fix(codex): surface capacity errors embedded in 200-OK SSE streams (#6710) * fix(codex): surface capacity errors embedded in 200-OK SSE streams Codex sometimes answers with HTTP 200 and a text/event-stream body whose payload carries a transient error mid-stream (e.g. "Selected model is at capacity...", server_is_overloaded, service_unavailable_error). Because the outer HTTP status was 200, this looked like a successful response to every caller — no retry, no circuit breaker, and no combo/account fallback ever engaged, so a healthy account sat idle while the request silently failed or truncated. Add peekCodexSseTransientError() to open-sse/executors/codex.ts: it peeks the first bytes of a text/event-stream Codex response, pattern-matches the known transient-error signatures, and converts a match into a real 503 Response via errorResponse() (Hard Rule #12 — sanitized, never raw upstream text). A 503 is already a recognized provider-failure status in accountFallback.ts, so combo routing and connection cooldown pick it up automatically. When no error signature is found, the peeked prefix is prepended back onto the remaining upstream body so the passthrough stays byte-identical to the unmodified response. Regression guard: tests/unit/codex-sse-capacity-fallback.test.ts — a model-at-capacity payload and a server_is_overloaded/service_unavailable_error payload both convert to 503; a normal single-chunk SSE stream and one split across multiple network chunks both reassemble byte-for-byte unchanged. Inspired-by: https://github.com/decolua/9router/pull/2452 (sub-bug #3 only — OmniRoute already covers PR #2452's other two sub-bugs: service_tier "fast" normalization and reasoning_effort "max" normalization). Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> * chore(6710): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> * fix(volcengine): clamp Kimi max_tokens to Ark endpoint cap (#6712) * fix(volcengine): clamp Kimi max_tokens to Ark endpoint cap VolcEngine Ark's Kimi coding-plan endpoint (ark.cn-beijing.volces.com) enforces max_tokens <= 32768 server-side and returns 400 "integer above maximum value, expected a value <= 32768" for anything over that ceiling. OmniRoute's StripRule only supported dropping params outright, with no numeric clamp mechanism, so a client sending a larger max_tokens (common default, e.g. 65536) 400s outright against volcengine's kimi-k2-5-260127. The 32768 cap is independently confirmed against two live-endpoint bug reports hitting this exact Ark endpoint for both kimi-k2.5 and kimi-k2.7-code (NousResearch/hermes-agent#51773, MoonshotAI/kimi-cli#1124), not just upstream's own value — same cap upstream 9router#2460 uses. StripRule gains two optional fields: `clampToModelMaxOutput` (clamp to the model's own catalog maxOutputTokens ceiling, when set) and `maxOutputCap` (a fixed endpoint-imposed ceiling); when both apply, the lower wins. The new rule is scoped to the literal id `kimi-k2-5-260127` (OmniRoute's real volcengine Kimi model, not upstream's `Kimi-K2.7-Code`), not a broad /kimi/i regex, so it can never clamp an unrelated future Kimi listing whose Ark cap may differ. glm-4-7-251222 (the other volcengine model) is unaffected. Inspired-by: https://github.com/decolua/9router/pull/2460 Co-authored-by: whale9820 * chore(6712): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: whale9820 * fix(antigravity): surface aborted Gemini tool calls off end_turn (#6713) * fix(antigravity): surface aborted Gemini tool calls off end_turn Gemini/Antigravity aborts a turn with finishReason MALFORMED_FUNCTION_CALL (or a sibling like UNEXPECTED_TOOL_CALL) instead of completing cleanly. Both Claude-facing translators collapsed these to a clean end_turn, hiding the aborted tool call as a successful completion: - the OpenAI hub path (openai-to-claude.ts convertFinishReason default), and - the DIRECT Gemini->Claude path (gemini-to-claude.ts), which is the one Claude Code actually hits through an antigravity/Gemini-routed model. Add isAbortFinishReason() to finishReason.ts and map these reasons to tool_use on both paths; genuinely unknown reasons still fall back to end_turn. Co-authored-by: anhdiepmmk Inspired-by: https://github.com/decolua/9router/pull/2462 * chore(6713): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: anhdiepmmk * fix(translator): strip empty cloud_base_branch from Cursor Subagent tool call (#6729) * fix(translator): strip empty cloud_base_branch from Cursor Subagent tool call (port from 9router#2446) The Responses->Chat tool-arg cleanup (stripEmptyOptionalToolArgs) only stripped empty-string/empty-array optional args for Claude Code's Read tool. Cursor's local Subagent tool call therefore passed through with the cloud-only field cloud_base_branch: "", which Cursor rejects ("cloud_base_branch may only be specified when environment equals cloud") before starting the subagent. Extend the cleanup to an allowlist of Read + Subagent; arbitrary tools stay untouched. Reported-by: like3213934360-lab (https://github.com/decolua/9router/issues/2446) * chore(6729): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) * fix(translator): defer content_block_start until GLM streams the tool name (#6730) * fix(translator): defer content_block_start until GLM streams the tool name (port from 9router#2077) GLM 5.2 (and similar OpenAI-compatible upstreams) stream a tool call's id and function.name across separate SSE delta chunks. The openai-to-claude streaming translator emitted content_block_start immediately on the id-only chunk with an empty name; the Claude SSE protocol cannot patch a block after emission, so the later name-only chunk was dropped and Claude Code rejected the tool_use with an empty tool name / "No such tool available:". Defer content_block_start until the name arrives (start on args if they arrive first), and emit a start for any orphaned id-only tool call at finish so content_block_stop is never orphaned. Reported-by: itiwant (https://github.com/decolua/9router/issues/2077) * chore(6730): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) * feat(dashboard): add search to Playground model picker dropdown (#4086) (#6811) * feat(dashboard): add search to Playground model picker dropdown (#4086) The shared ModelSelectModal (combo builder + CLI-code cards) already had search, but the Playground's raw model setRefreshToken(e.target.value)} placeholder="Token will be auto-filled..." @@ -485,6 +405,7 @@ export default function KiroAuthModal({ API Key * setApiKey(e.target.value)} placeholder={`Paste your ${providerLabel} API key...`} diff --git a/src/shared/components/KiroOAuthWrapper.tsx b/src/shared/components/KiroOAuthWrapper.tsx index 19b91ee85f..15afd8bbd3 100644 --- a/src/shared/components/KiroOAuthWrapper.tsx +++ b/src/shared/components/KiroOAuthWrapper.tsx @@ -58,11 +58,11 @@ export default function KiroOAuthWrapper({ setIdcConfig(null); }; - const handleSocialSuccess = () => { + const handleSocialSuccess = useCallback(() => { setAuthMethod(null); setSocialProvider(null); onSuccess?.(); - }; + }, [onSuccess]); const handleDeviceSuccess = () => { setAuthMethod(null); diff --git a/src/shared/components/KiroSocialOAuthModal.tsx b/src/shared/components/KiroSocialOAuthModal.tsx index cd8ce56475..9c457683a6 100644 --- a/src/shared/components/KiroSocialOAuthModal.tsx +++ b/src/shared/components/KiroSocialOAuthModal.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import Modal from "./Modal"; import Button from "./Button"; import { copyToClipboard } from "@/shared/utils/clipboard"; +import { getNextKiroSocialPollInterval } from "@/lib/oauth/kiroSocialPoll"; type KiroSocialOAuthModalProps = { isOpen: boolean; @@ -26,10 +27,28 @@ export default function KiroSocialOAuthModal({ const [error, setError] = useState(null); const [userCode, setUserCode] = useState(""); const [authUrl, setAuthUrl] = useState(""); - const pollRef = useRef | null>(null); + const pollRef = useRef | null>(null); + const onSuccessRef = useRef(onSuccess); + + useEffect(() => { + onSuccessRef.current = onSuccess; + }, [onSuccess]); useEffect(() => { if (!isOpen || !provider) return; + let cancelled = false; + + const stopPolling = () => { + if (pollRef.current) clearTimeout(pollRef.current); + pollRef.current = null; + }; + + const fail = (message: string) => { + stopPolling(); + if (cancelled) return; + setError(message); + setStep("error"); + }; const initAuth = async () => { try { @@ -38,6 +57,7 @@ export default function KiroSocialOAuthModal({ const res = await fetch(`/api/oauth/kiro/social-authorize?provider=${provider}`); const data = await res.json(); + if (cancelled) return; if (!res.ok) { throw new Error(data.error || "Failed to start authorization"); @@ -47,8 +67,23 @@ export default function KiroSocialOAuthModal({ setAuthUrl(data.authUrl || ""); setStep("polling"); - const interval = (data.interval || 5) * 1000; - pollRef.current = setInterval(async () => { + const baseIntervalMs = Math.max(1, Number(data.interval) || 5) * 1000; + let currentIntervalMs = baseIntervalMs; + const expiresAt = Date.now() + Math.max(1, Number(data.expiresIn) || 300) * 1000; + + const schedule = (delayMs: number) => { + if (cancelled) return; + pollRef.current = setTimeout(poll, delayMs); + }; + + const poll = async () => { + pollRef.current = null; + if (cancelled) return; + if (Date.now() >= expiresAt) { + fail("Authorization expired. Start the login flow again."); + return; + } + try { const pollRes = await fetch("/api/oauth/kiro/social-exchange", { method: "POST", @@ -56,36 +91,44 @@ export default function KiroSocialOAuthModal({ body: JSON.stringify({ deviceCode: data.deviceCode, provider, targetProvider }), }); const pollData = await pollRes.json(); + if (cancelled) return; if (pollData.success) { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; + stopPolling(); setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); + return; } + + if (!pollData.pending) { + fail(pollData.error || "Authorization failed"); + return; + } + + currentIntervalMs = getNextKiroSocialPollInterval(currentIntervalMs, pollData.error); + schedule(currentIntervalMs); } catch { - // Network error, keep polling + schedule(currentIntervalMs); } - }, interval); + }; + + schedule(baseIntervalMs); } catch (err: any) { - setError(err.message); - setStep("error"); + fail(err.message); } }; initAuth(); return () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } + cancelled = true; + stopPolling(); }; - }, [isOpen, provider]); + }, [isOpen, provider, targetProvider]); const handleClose = () => { if (pollRef.current) { - clearInterval(pollRef.current); + clearTimeout(pollRef.current); pollRef.current = null; } onClose(); diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 19c642eda9..7c4a8406c4 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -450,13 +450,6 @@ export const DEFAULT_PRICING_OAUTH = { }, }, kiro: { - "claude-fable-5": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 112.5, - cache_creation: 15.0, - }, "claude-sonnet-4.5": { input: 3.0, output: 15.0, @@ -471,42 +464,6 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 2.5, cache_creation: 0.5, }, - // Models from issue #334 - "claude-sonnet-4": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, - "claude-opus-4.8": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 75.0, - cache_creation: 15.0, - }, - "claude-opus-4.7": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 75.0, - cache_creation: 15.0, - }, - "claude-opus-4.6": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 75.0, - cache_creation: 15.0, - }, - "claude-sonnet-4.6": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, "claude-sonnet-5": { input: 3.0, output: 15.0, @@ -558,22 +515,6 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 8.0, cache_creation: 2.0, }, - // Kiro "Auto" pricing — retained for both the upstream "auto" id and the - // local "auto-kiro" selector. The translator maps auto-kiro back to auto. - auto: { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, - "auto-kiro": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, // Kiro's GPT-5.6 family (kiro.dev/changelog/models, 2026-07-14) — same // per-tier rates the codex/openai aliases already bill at. "gpt-5.6-sol": GPT_5_6_SOL_PRICING, diff --git a/tests/unit/kiro-auto-import-name-dedup-3615.test.ts b/tests/unit/kiro-auto-import-name-dedup-3615.test.ts index e491b6d309..f354514acc 100644 --- a/tests/unit/kiro-auto-import-name-dedup-3615.test.ts +++ b/tests/unit/kiro-auto-import-name-dedup-3615.test.ts @@ -35,8 +35,15 @@ test.after(() => { import { deriveKiroConnectionName, findKiroConnectionByProfileArn, + resolveKiroCliAuthMethod, } from "../../src/app/api/oauth/kiro/auto-import/route.ts"; +test("kiro-cli source keeps Builder ID and IdC as distinct auth methods", () => { + assert.equal(resolveKiroCliAuthMethod(undefined), "builder-id"); + assert.equal(resolveKiroCliAuthMethod(null), "builder-id"); + assert.equal(resolveKiroCliAuthMethod("arn:aws:codewhisperer:eu-central-1:1:profile/IDC"), "idc"); +}); + // ── (a) Display name derivation ─────────────────────────────────────────────── test("derives email as name when email is present", () => { @@ -132,10 +139,7 @@ test("findKiroConnectionByProfileArn returns the matching connection", async () }); test("findKiroConnectionByProfileArn returns null when no match exists", async () => { - const result = await findKiroConnectionByProfileArn( - [fakeConnectionNoArn], - FAKE_PROFILE_ARN - ); + const result = await findKiroConnectionByProfileArn([fakeConnectionNoArn], FAKE_PROFILE_ARN); assert.equal(result, null); }); @@ -145,9 +149,6 @@ test("findKiroConnectionByProfileArn returns null for empty connection list", as }); test("findKiroConnectionByProfileArn returns null when profileArn arg is undefined", async () => { - const result = await findKiroConnectionByProfileArn( - [fakeConnectionWithArn], - undefined - ); + const result = await findKiroConnectionByProfileArn([fakeConnectionWithArn], undefined); assert.equal(result, null); }); diff --git a/tests/unit/kiro-available-models.test.ts b/tests/unit/kiro-available-models.test.ts index 58a0841f34..d1cf7ae0cb 100644 --- a/tests/unit/kiro-available-models.test.ts +++ b/tests/unit/kiro-available-models.test.ts @@ -7,9 +7,10 @@ import { buildKiroModelsEndpoints, fetchKiroAvailableModels, clearKiroModelCache, + isObsoleteKiroModelAlias, } from "../../open-sse/services/kiroModels.ts"; -const FALLBACK = [{ id: "auto-kiro", name: "Auto" }, { id: "claude-sonnet-4.6" }]; +const FALLBACK = [{ id: "claude-sonnet-4.5" }, { id: "deepseek-3.2" }]; beforeEach(() => { clearKiroModelCache(); @@ -75,14 +76,7 @@ test("fetchKiroAvailableModels: simple (Builder ID) account, us-east-1, origin-o }); assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id).sort(), [ - "auto", - "auto-thinking", - "claude-sonnet-4.6", - "claude-sonnet-4.6-agentic", - "claude-sonnet-4.6-thinking", - "claude-sonnet-4.6-thinking-agentic", - ]); + assert.deepEqual(result.models.map((m) => m.id).sort(), ["auto", "claude-sonnet-4.6"]); assert.deepEqual(calls, [ "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR", ]); @@ -106,12 +100,7 @@ test("fetchKiroAvailableModels: IAM Identity Center account, region-matched endp assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - [ - "claude-opus-4.8", - "claude-opus-4.8-thinking", - "claude-opus-4.8-agentic", - "claude-opus-4.8-thinking-agentic", - ] + ["claude-opus-4.8"] ); assert.equal( calls[0], @@ -142,12 +131,7 @@ test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - [ - "claude-sonnet-4.6", - "claude-sonnet-4.6-thinking", - "claude-sonnet-4.6-agentic", - "claude-sonnet-4.6-thinking-agentic", - ] + ["claude-sonnet-4.6"] ); // origin-only attempted first, then profileArn retry. assert.equal(calls.length, 2); @@ -155,6 +139,58 @@ test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", assert.ok(calls[1].includes("profileArn=arn%3Aaws%3Acodewhisperer")); }); +test("fetchKiroAvailableModels only exposes a functional Thinking alias", async () => { + const fetchImpl = (async () => + jsonResponse({ + models: [ + { modelId: "claude-sonnet-5" }, + { modelId: "claude-sonnet-4.5" }, + { modelId: "deepseek-3.2" }, + ], + })) as unknown as typeof fetch; + + const result = await fetchKiroAvailableModels({ + accessToken: "tok", + providerSpecificData: { authMethod: "builder-id" }, + fetchImpl, + }); + + assert.deepEqual( + result.models.map((model) => model.id), + ["claude-sonnet-5", "claude-sonnet-5-thinking", "claude-sonnet-4.5", "deepseek-3.2"] + ); +}); + +test("isObsoleteKiroModelAlias filters stale cached aliases", () => { + assert.equal(isObsoleteKiroModelAlias("auto-kiro"), true); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-5-agentic"), true); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-4.5-thinking"), true); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-5-thinking"), false); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-4.5"), false); +}); + +test("fetchKiroAvailableModels sends auth-method headers for API key and External IdP", async () => { + const seen: Array = []; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + seen.push(new Headers(init?.headers)); + return jsonResponse({ models: [{ modelId: "claude-sonnet-5" }] }); + }) as unknown as typeof fetch; + + await fetchKiroAvailableModels({ + accessToken: "api-key", + providerSpecificData: { authMethod: "api_key", clientId: "api-client" }, + fetchImpl, + }); + await fetchKiroAvailableModels({ + accessToken: "external-token", + providerSpecificData: { authMethod: "external_idp", clientId: "external-client" }, + fetchImpl, + }); + + assert.equal(seen[0].get("tokentype"), "API_KEY"); + assert.equal(seen[1].get("tokentype"), "EXTERNAL_IDP"); +}); + test("fetchKiroAvailableModels: falls back to static catalog when no token", async () => { const result = await fetchKiroAvailableModels({ accessToken: "", @@ -164,7 +200,7 @@ test("fetchKiroAvailableModels: falls back to static catalog when no token", asy assert.equal(result.source, "fallback"); assert.deepEqual( result.models.map((m) => m.id), - ["auto-kiro", "claude-sonnet-4.6"] + ["claude-sonnet-4.5", "deepseek-3.2"] ); }); @@ -180,6 +216,6 @@ test("fetchKiroAvailableModels: falls back when every upstream attempt fails", a assert.equal(result.source, "fallback"); assert.deepEqual( result.models.map((m) => m.id), - ["auto-kiro", "claude-sonnet-4.6"] + ["claude-sonnet-4.5", "deepseek-3.2"] ); }); diff --git a/tests/unit/kiro-connection-identity.test.ts b/tests/unit/kiro-connection-identity.test.ts new file mode 100644 index 0000000000..c5ac2b134f --- /dev/null +++ b/tests/unit/kiro-connection-identity.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; + +const connections = [ + { + id: "profile-match", + authType: "oauth", + name: "Kiro profile", + email: "profile@example.com", + providerSpecificData: { profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/A" }, + }, + { + id: "builder-match", + authType: "oauth", + name: "Kiro Builder ID", + providerSpecificData: { clientId: "builder-client" }, + }, + { + id: "email-match", + authType: "oauth", + name: "Kiro Social", + email: "social@example.com", + providerSpecificData: {}, + }, + { + id: "name-match", + authType: "apikey", + name: "Kiro API Key (us-east-1, abc123)", + providerSpecificData: { authMethod: "api_key" }, + }, +]; + +test("findKiroConnectionByIdentity prefers an exact trimmed profile ARN", () => { + const match = findKiroConnectionByIdentity(connections, { + profileArn: " arn:aws:codewhisperer:us-east-1:1:profile/A ", + clientId: "builder-client", + }); + assert.equal(match?.id, "profile-match"); +}); + +test("findKiroConnectionByIdentity deduplicates profileless Builder ID by clientId", () => { + const match = findKiroConnectionByIdentity(connections, { clientId: " builder-client " }); + assert.equal(match?.id, "builder-match"); +}); + +test("findKiroConnectionByIdentity falls back to email and API-key fingerprint name", () => { + assert.equal( + findKiroConnectionByIdentity(connections, { email: "SOCIAL@EXAMPLE.COM" })?.id, + "email-match" + ); + assert.equal( + findKiroConnectionByIdentity(connections, { + name: "kiro api key (us-east-1, ABC123)", + })?.id, + "name-match" + ); +}); + +test("findKiroConnectionByIdentity never matches empty identity values", () => { + assert.equal(findKiroConnectionByIdentity(connections, {}), null); +}); + +test("findKiroConnectionByIdentity never overwrites a different authentication type", () => { + assert.equal( + findKiroConnectionByIdentity(connections, { + authType: "apikey", + profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/A", + email: "profile@example.com", + }), + null + ); + assert.equal( + findKiroConnectionByIdentity(connections, { + authType: "oauth", + name: "Kiro API Key (us-east-1, abc123)", + }), + null + ); +}); diff --git a/tests/unit/kiro-iam-profilearn-usage.test.ts b/tests/unit/kiro-iam-profilearn-usage.test.ts index 9bfc7f4132..50e3f265b1 100644 --- a/tests/unit/kiro-iam-profilearn-usage.test.ts +++ b/tests/unit/kiro-iam-profilearn-usage.test.ts @@ -131,6 +131,52 @@ test("discoverKiroProfileArn returns undefined for empty profiles or non-ok resp } }); +test("getKiroUsage fetches Builder ID quotas without a profile ARN", async () => { + const originalFetch = globalThis.fetch; + const authMethods = ["builder-id"]; + const requests: Array<{ target: string; body: Record }> = []; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const headers = init?.headers as Record; + const target = String(headers?.["x-amz-target"] || ""); + const body = JSON.parse(String(init?.body || "{}")) as Record; + requests.push({ target, body }); + return new Response( + JSON.stringify({ + subscriptionInfo: { subscriptionTitle: "Kiro Pro" }, + usageBreakdownList: [ + { + resourceType: "AGENTIC_REQUEST", + currentUsageWithPrecision: 3, + usageLimitWithPrecision: 10, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + try { + for (const authMethod of authMethods) { + const result = (await getKiroUsage("profileless-token", { + authMethod, + region: "us-east-1", + })) as { plan?: string; quotas?: Record }; + assert.equal(result.plan, "Kiro Pro"); + assert.equal(result.quotas?.agentic_request.used, 3); + assert.equal(result.quotas?.agentic_request.total, 10); + } + + assert.equal(requests.length, authMethods.length); + for (const request of requests) { + assert.equal(request.target, "AmazonCodeWhispererService.GetUsageLimits"); + assert.equal("profileArn" in request.body, false); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + // Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported" // with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its // token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired, diff --git a/tests/unit/kiro-iam-region.test.ts b/tests/unit/kiro-iam-region.test.ts index a3e8bf8611..d252ee91d7 100644 --- a/tests/unit/kiro-iam-region.test.ts +++ b/tests/unit/kiro-iam-region.test.ts @@ -91,7 +91,28 @@ test("kiro.postExchange returns null when no profile is available (AWS Builder I } }); -test("kiro.postExchange never throws on network failure", async () => { +test("kiro.postExchange skips profile discovery for an identified Builder ID flow", async () => { + const originalFetch = global.fetch; + let calls = 0; + global.fetch = (async () => { + calls += 1; + throw new Error("Builder ID must not probe ListAvailableProfiles"); + }) as typeof fetch; + + try { + const extra = await kiro.postExchange({ + access_token: "builder-token", + _region: "us-east-1", + _authMethod: "builder-id", + }); + assert.equal(extra, null); + assert.equal(calls, 0); + } finally { + global.fetch = originalFetch; + } +}); + +test("kiro.postExchange never throws on network failure", async () => { const originalFetch = global.fetch; global.fetch = (async () => { throw new Error("network down"); @@ -112,6 +133,7 @@ test("kiro.mapTokens stores the discovered profileArn from postExchange extra", mapped.providerSpecificData.profileArn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" ); + assert.equal(mapped.providerSpecificData.authMethod, "idc"); }); test("kiro.mapTokens omits profileArn when postExchange found none", () => { @@ -120,4 +142,5 @@ test("kiro.mapTokens omits profileArn when postExchange found none", () => { null ); assert.equal("profileArn" in mapped.providerSpecificData, false); + assert.equal(mapped.providerSpecificData.authMethod, "builder-id"); }); diff --git a/tests/unit/kiro-model-aliases.test.ts b/tests/unit/kiro-model-aliases.test.ts new file mode 100644 index 0000000000..b9635719c6 --- /dev/null +++ b/tests/unit/kiro-model-aliases.test.ts @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildKiroPayload } from "../../open-sse/translator/request/openai-to-kiro.ts"; + +const body = { messages: [{ role: "user", content: "Hello" }] }; + +test("buildKiroPayload rejects removed or non-functional Kiro aliases", () => { + assert.throws(() => buildKiroPayload("auto-kiro", body, true, {}), /not a real Kiro/); + assert.throws( + () => buildKiroPayload("claude-sonnet-5-agentic", body, true, {}), + /agentic aliases are not supported/ + ); + assert.throws( + () => buildKiroPayload("claude-sonnet-4.5-thinking", body, true, {}), + /does not support the '-thinking' alias/ + ); +}); diff --git a/tests/unit/kiro-social-poll.test.ts b/tests/unit/kiro-social-poll.test.ts new file mode 100644 index 0000000000..359cfa6d0a --- /dev/null +++ b/tests/unit/kiro-social-poll.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classifyKiroSocialPoll, getNextKiroSocialPollInterval } from "@/lib/oauth/kiroSocialPoll"; + +test("slow_down permanently increases the polling interval for this authorization flow", () => { + const slowed = getNextKiroSocialPollInterval(5_000, "slow_down"); + assert.equal(slowed, 10_000); + assert.equal(getNextKiroSocialPollInterval(slowed, "authorization_pending"), 10_000); + assert.equal(getNextKiroSocialPollInterval(slowed, "network_error"), 10_000); +}); + +test("classifyKiroSocialPoll keeps only documented pending states retryable", () => { + assert.deepEqual(classifyKiroSocialPoll(false, 400, { error: "authorization_pending" }), { + kind: "pending", + error: "authorization_pending", + }); + assert.deepEqual(classifyKiroSocialPoll(false, 429, { error: "slow_down" }), { + kind: "pending", + error: "slow_down", + }); +}); + +test("classifyKiroSocialPoll stops on denied, expired and malformed responses", () => { + assert.deepEqual(classifyKiroSocialPoll(false, 403, { error: "access_denied" }), { + kind: "error", + error: "access_denied", + status: 403, + }); + assert.deepEqual(classifyKiroSocialPoll(true, 200, { error: "expired_token" }), { + kind: "error", + error: "expired_token", + status: 400, + }); + assert.deepEqual(classifyKiroSocialPoll(true, 200, {}), { + kind: "error", + error: "invalid_token_response", + status: 502, + }); +}); + +test("classifyKiroSocialPoll accepts a token response", () => { + assert.deepEqual(classifyKiroSocialPoll(true, 200, { accessToken: "token" }), { + kind: "success", + }); +}); diff --git a/tests/unit/oauth-kiro-idc.test.ts b/tests/unit/oauth-kiro-idc.test.ts index 015ffe259c..b0e082fa73 100644 --- a/tests/unit/oauth-kiro-idc.test.ts +++ b/tests/unit/oauth-kiro-idc.test.ts @@ -49,6 +49,7 @@ test("kiro.requestDeviceCode returns resolved region for IDC token endpoint", as }); assert.equal(result._region, "ap-southeast-1"); + assert.equal(result._authMethod, "idc"); assert.equal(result._clientId, "client-ap"); assert.equal(result._clientSecret, "secret-ap"); @@ -80,13 +81,19 @@ test("kiro.pollToken uses region provided by extraData", async () => { { tokenUrl: "https://oidc.us-east-1.amazonaws.com/token" }, "device-code", null, - { _clientId: "cid", _clientSecret: "csecret", _region: "ap-southeast-1" } + { + _clientId: "cid", + _clientSecret: "csecret", + _region: "ap-southeast-1", + _authMethod: "idc", + } ); assert.equal(requestedUrl, "https://oidc.ap-southeast-1.amazonaws.com/token"); assert.equal(result.ok, true); assert.equal(result.data.access_token, "access"); assert.equal(result.data._region, "ap-southeast-1"); + assert.equal(result.data._authMethod, "idc"); } finally { global.fetch = originalFetch; } @@ -100,6 +107,7 @@ test("kiro.mapTokens persists region into providerSpecificData", () => { _clientId: "cid", _clientSecret: "csec", _region: "ap-southeast-1", + _authMethod: "idc", }); assert.equal(mapped.accessToken, "at"); @@ -108,6 +116,7 @@ test("kiro.mapTokens persists region into providerSpecificData", () => { assert.equal(mapped.providerSpecificData.clientId, "cid"); assert.equal(mapped.providerSpecificData.clientSecret, "csec"); assert.equal(mapped.providerSpecificData.region, "ap-southeast-1"); + assert.equal(mapped.providerSpecificData.authMethod, "idc"); }); test("kiro.mapTokens defaults region to undefined when not provided", () => { @@ -120,4 +129,5 @@ test("kiro.mapTokens defaults region to undefined when not provided", () => { }); assert.equal(mapped.providerSpecificData.region, undefined); + assert.equal(mapped.providerSpecificData.authMethod, "builder-id"); }); diff --git a/tests/unit/shared/components/KiroAuthModal.test.tsx b/tests/unit/shared/components/KiroAuthModal.test.tsx index 948072d217..f363e234e7 100644 --- a/tests/unit/shared/components/KiroAuthModal.test.tsx +++ b/tests/unit/shared/components/KiroAuthModal.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cleanupCallbacks: Array<() => void> = []; @@ -21,6 +22,14 @@ function setInputValue(input: HTMLInputElement, value: string): void { input.dispatchEvent(new Event("input", { bubbles: true })); } +function withIntl(children: React.ReactNode) { + return ( + + {children} + + ); +} + describe("KiroAuthModal", () => { beforeEach(() => { ( @@ -43,7 +52,9 @@ describe("KiroAuthModal", () => { const onMethodSelect = vi.fn(); await act(async () => { - root.render(); + root.render( + withIntl() + ); }); const googleButton = Array.from(container.querySelectorAll("button")).find((button) => @@ -78,7 +89,9 @@ describe("KiroAuthModal", () => { try { await act(async () => { - root.render(); + root.render( + withIntl() + ); }); const apiKeyButton = Array.from(container.querySelectorAll("button")).find( @@ -98,6 +111,8 @@ describe("KiroAuthModal", () => { setInputValue(apiKeyInput, "ksk_test_key"); }); + expect(apiKeyInput.type).toBe("password"); + await act(async () => { saveButton?.click(); }); @@ -109,4 +124,93 @@ describe("KiroAuthModal", () => { globalThis.fetch = originalFetch; } }); + + it("treats successful auto-import as completed instead of importing twice", async () => { + const { default: KiroAuthModal } = await import("@/shared/components/KiroAuthModal"); + const container = makeContainer(); + const root = createRoot(container); + const calls: string[] = []; + const onMethodSelect = vi.fn(() => calls.push("select")); + const onClose = vi.fn(() => calls.push("close")); + const originalFetch = globalThis.fetch; + + globalThis.fetch = vi.fn(async () => { + return new Response(JSON.stringify({ found: true, source: "kiro-cli-sqlite" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + await act(async () => { + root.render( + withIntl() + ); + }); + + const importButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.querySelector("h3")?.textContent === "Import Token" + ); + await act(async () => { + importButton?.click(); + }); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/oauth/kiro/auto-import?targetProvider=kiro" + ); + expect(onMethodSelect).toHaveBeenCalledWith("import"); + expect(calls).toEqual(["select", "close"]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not restart an active social login when the wrapper rerenders", async () => { + const { default: KiroOAuthWrapper } = await import("@/shared/components/KiroOAuthWrapper"); + const container = makeContainer(); + const root = createRoot(container); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/social-authorize")) { + return new Response( + JSON.stringify({ + userCode: "ABCD-EFGH", + authUrl: "https://example.test/authorize", + deviceCode: "device-code", + interval: 60, + expiresIn: 300, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + globalThis.fetch = fetchMock as typeof fetch; + + try { + await act(async () => { + root.render(withIntl()); + }); + + const googleButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Google Account") + ); + await act(async () => { + googleButton?.click(); + }); + + await act(async () => { + root.render(withIntl()); + }); + + expect( + fetchMock.mock.calls.filter(([input]) => String(input).includes("/social-authorize")) + ).toHaveLength(1); + } finally { + await act(async () => root.unmount()); + globalThis.fetch = originalFetch; + } + }); }); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 4cf19efebb..803aa39fd2 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -966,7 +966,7 @@ test("OpenAI -> Kiro serializes non-string role:tool content to non-empty text ( }); // Only Claude models support images in Kiro. Non-Claude Kiro models -// (deepseek-3.2, minimax-m2.5, glm-5, qwen3-coder-next, auto-kiro) must NOT +// (deepseek-3.2, minimax-m2.5, glm-5, qwen3-coder-next) must NOT // receive image attachments — attaching them is wrong for those models. const PNG_DATA_URL = "data:image/png;base64,aGVsbG8="; @@ -1016,8 +1016,8 @@ test("OpenAI -> Kiro drops images for non-Claude models (deepseek)", () => { ); }); -test("OpenAI -> Kiro drops images for non-Claude models (glm / auto-kiro)", () => { - for (const model of ["glm-5", "minimax-m2.5", "qwen3-coder-next", "auto-kiro"]) { +test("OpenAI -> Kiro drops images for other non-Claude Kiro models", () => { + for (const model of ["glm-5", "minimax-m2.5", "qwen3-coder-next"]) { const result = buildImageRequest(model); const images = result.conversationState.currentMessage.userInputMessage.images; assert.ok( @@ -1031,7 +1031,7 @@ test("buildKiroPayload rejects the Anthropic-only [1m] context suffix before Bed const body = { messages: [{ role: "user", content: "Hello" }] }; assert.throws( - () => buildKiroPayload("claude-opus-4.7-thinking-agentic[1m]", body, true, {}), + () => buildKiroPayload("claude-sonnet-5-thinking[1m]", body, true, {}), /\[1m\]' suffix is not supported by Kiro upstream/, "kr/* model ids carrying [1m] must be rejected, not forwarded to AWS Bedrock" ); @@ -1046,14 +1046,14 @@ test("buildKiroPayload accepts kr/* model ids without the [1m] suffix", () => { ); }); -test("buildKiroPayload strips local Kiro selector suffixes before upstream", () => { +test("buildKiroPayload strips the supported Thinking selector before upstream", () => { const body = { messages: [{ role: "user", content: "Hello" }] }; - const result = buildKiroPayload("claude-sonnet-5-thinking-agentic", body, true, {}); + const result = buildKiroPayload("claude-sonnet-5-thinking", body, true, {}); assert.equal( result.conversationState.currentMessage.userInputMessage.modelId, "claude-sonnet-5", - "local -thinking/-agentic aliases must not be forwarded to Kiro" + "the local -thinking alias must not be forwarded to Kiro" ); assert.equal( result.additionalModelRequestFields?.output_config?.effort, @@ -1062,13 +1062,6 @@ test("buildKiroPayload strips local Kiro selector suffixes before upstream", () ); }); -test("buildKiroPayload maps auto-kiro selector to Kiro auto upstream id", () => { - const body = { messages: [{ role: "user", content: "Hello" }] }; - - const result = buildKiroPayload("auto-kiro", body, true, {}); - assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "auto"); -}); - // Regression for upstream decolua/9router PR #2270: the dash->dot normalization's // trailing minor-version group must be bounded (1-2 digits), otherwise a // date-suffixed Claude model id (e.g. claude-opus-4-20250514) gets corrupted into diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index c2185d9e7a..1110065263 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -743,9 +743,11 @@ test("usage service covers Codex, Kiro and Kimi usage parsing and error branches const kiroNoArn: any = await usageService.getUsageForProvider({ provider: "kiro", accessToken: "kiro-token", - providerSpecificData: {}, + providerSpecificData: { authMethod: "builder-id", region: "us-east-1" }, }); - assert.match(kiroNoArn.message, /Profile ARN not available/i); + assert.equal(kiroNoArn.plan, "Kiro Pro"); + assert.equal(kiroNoArn.quotas.agentic_request.used, 12); + assert.equal(kiroNoArn.quotas.agentic_request_freetrial.remaining, 3); const kiro: any = await usageService.getUsageForProvider({ provider: "kiro",