From ae2a0e3e404aaf032cdb5961e99d6931239f2397 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 12 Jun 2026 23:52:03 -0300 Subject: [PATCH 01/36] =?UTF-8?q?chore(release):=20v3.8.24=20=E2=80=94=20c?= =?UTF-8?q?ycle=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/openapi.yaml | 2 +- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- open-sse/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 3fce18a61df..81eeaa60a48 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.23 + version: 3.8.24 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index fdb25d1b993..65f8bbb3ded 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.23", + "version": "3.8.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.23", + "version": "3.8.24", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index abcd8110b72..eeb86c52a8b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.23", + "version": "3.8.24", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/package.json b/open-sse/package.json index b9b9f1fd179..42c91eaced0 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.23", + "version": "3.8.24", "description": "Express SSE sidecar for OmniRoute โ€” handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 25c8f8dfc6c..60a548c2c59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.23", + "version": "3.8.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.23", + "version": "3.8.24", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21587,7 +21587,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.23" + "version": "3.8.24" } } } diff --git a/package.json b/package.json index e41b18509ce..470f74c0c74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.23", + "version": "3.8.24", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From 62a0f6a542dee41679c68758f71ad09c76bb8a3f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 13 Jun 2026 00:06:35 -0300 Subject: [PATCH 02/36] fix(publish): clean opencode-plugin node_modules after tsup build to prevent E415 hard-link tarball rejection --- scripts/build/prepublish.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index b5c2e14dfba..afcdca2be35 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -309,6 +309,15 @@ if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package } else { console.log(" โœ… @omniroute/opencode-plugin dist/ already present (skipping rebuild)"); } + // Remove plugin node_modules after build โ€” hard links created by npm install on Linux + // (CI runner) end up in the tarball as LINK entries, which npm registry rejects with + // E415 "Hard link is not allowed". The node_modules are only needed for the tsup build; + // they must not ship in the published package. + const pluginNodeModules = join(opencodePluginSrc, "node_modules"); + if (existsSync(pluginNodeModules)) { + rmSync(pluginNodeModules, { recursive: true, force: true }); + console.log(" ๐Ÿงน Removed @omniroute/opencode-plugin/node_modules (hard link guard)"); + } } else { console.log(" โญ๏ธ @omniroute/opencode-plugin not found in workspace (skipping build)"); } From 33667fcf3acdf4b0551d73c8331acef5455a62e2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:15:53 -0300 Subject: [PATCH 03/36] =?UTF-8?q?fix(cli):=20coerce=20ServerSupervisor=20e?= =?UTF-8?q?xit=20code=20to=20number=20=E2=80=94=20prevents=20TypeError=20o?= =?UTF-8?q?n=20Node.js=20v24=20(#3748)=20(#3750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node.js v24 added strict type checking to process.exit() and throws TypeError [ERR_INVALID_ARG_TYPE] when given a non-number. The spawn 'error' event passes err.code (e.g. 'ENOENT') โ€” a string, not a number โ€” via `err.code ?? -1` (nullish coalescing doesn't help since 'ENOENT' is not null/undefined). handleExit() now normalises the code to a number at the top; the 'error' callback passes -1 unconditionally. --- CHANGELOG.md | 8 ++++++ bin/cli/runtime/processSupervisor.mjs | 11 ++++++--- tests/unit/cli-process-supervisor.test.ts | 30 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 234d26150c1..808fd81e6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ --- +## [3.8.24] โ€” TBD + +### ๐Ÿ› Fixed + +- fix(cli): `ServerSupervisor.handleExit` now coerces the exit code to a number before calling `process.exit()` โ€” Node.js v24 throws `TypeError [ERR_INVALID_ARG_TYPE]` when `process.exit()` receives a string (e.g. `'ENOENT'` from a spawn `error` event's `err.code`). The `error` callback also now passes `-1` instead of the raw `err.code`, which is an OS error string rather than a meaningful exit code. ([#3748](https://github.com/diegosouzapw/OmniRoute/issues/3748)) + +--- + ## [3.8.23] โ€” TBD ### โœจ New Features diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 135514cfbd0..b16de9670e6 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -42,17 +42,20 @@ export class ServerSupervisor { }); } - this.child.on("error", (err) => this.handleExit(err.code ?? -1, err)); + this.child.on("error", (err) => this.handleExit(-1, err)); this.child.on("exit", (code) => this.handleExit(code)); return this.child; } handleExit(code) { + // Node.js v24+ requires process.exit() to receive a number. Spawn-error events + // deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here. + const exitCode = typeof code === "number" ? code : null; cleanupPidFile("server"); - if (this.isShuttingDown || code === 0) { - process.exit(code || 0); + if (this.isShuttingDown || exitCode === 0) { + process.exit(exitCode ?? 0); return; } @@ -71,7 +74,7 @@ export class ServerSupervisor { } } this.dumpCrashLog(); - process.exit(code ?? 1); + process.exit(exitCode ?? 1); return; } diff --git a/tests/unit/cli-process-supervisor.test.ts b/tests/unit/cli-process-supervisor.test.ts index 8185d874a33..99012ad82ec 100644 --- a/tests/unit/cli-process-supervisor.test.ts +++ b/tests/unit/cli-process-supervisor.test.ts @@ -195,6 +195,36 @@ test("ServerSupervisor reseta restartCount apรณs processo viver >=30s", async () assert.equal(supervisor.restartCount, 1); // reset p/ 0, depois incrementado p/ 1 }); +// --- Node.js v24 compat: process.exit() must receive a number (#3748) --- + +test("ServerSupervisor.handleExit com string code nรฃo passa string para process.exit (#3748)", async () => { + const { ServerSupervisor } = await import("../../bin/cli/runtime/processSupervisor.mjs"); + + const exits: Array = []; + const origExit = process.exit.bind(process); + // @ts-ignore + process.exit = (code?: number | string) => exits.push(code); + + const supervisor = new ServerSupervisor({ + serverPath: "/fake/server.js", + env: {}, + maxRestarts: 0, + }); + // Simulates the 'error' event on child spawn failure: err.code = 'ENOENT' (string, not number). + // maxRestarts=0 โ†’ restartCount(0) >= maxRestarts(0) โ†’ process.exit() is called immediately. + supervisor.startedAt = Date.now() - 100; + supervisor.handleExit("ENOENT" as any); + + // @ts-ignore + process.exit = origExit; + assert.equal(exits.length, 1, "process.exit deve ser chamado exatamente 1 vez"); + assert.equal( + typeof exits[0], + "number", + `process.exit deve receber number, recebeu: ${typeof exits[0]} (${exits[0]})` + ); +}); + // --- pid.mjs multi-service --- test("writePidFile/readPidFile/cleanupPidFile operam por service", async () => { From de12d1c10942e414ef9e48d46745739fed1b9caa Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:40:57 -0400 Subject: [PATCH 04/36] Expose emergency fallback in Feature Flags (#3752) Adds OMNIROUTE_EMERGENCY_FALLBACK to the Feature Flags page as a runtime boolean (default on, no restart); resolves through the feature-flag stack so DB overrides can enable/disable it while preserving the raw env fallback path. Integrated into release/v3.8.24. --- docs/architecture/RESILIENCE_GUIDE.md | 1 + docs/reference/ENVIRONMENT.md | 2 +- open-sse/services/emergencyFallback.ts | 47 ++++++- src/i18n/messages/en.json | 1 + .../constants/featureFlagDefinitions.ts | 13 +- tests/unit/emergency-fallback-service.test.ts | 51 +++++++- tests/unit/feature-flags-settings.test.ts | 19 ++- .../unit/services/emergency-fallback.test.ts | 117 ++++++++++++++++-- 8 files changed, 234 insertions(+), 17 deletions(-) diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index 8424e508083..cd9f2c71dfa 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -124,6 +124,7 @@ Lists active lockouts with: provider, connection, model, reason, expiresAt. Oper - **Reset-aware routing** (v3.8.0) โ€” prioritizes connections by quota reset time. - **Background mode degradation** โ€” Responses API `background: true` degraded to sync with warning. - **Dynamic tool limit detection** โ€” backs off providers when tool count limits hit. +- **Emergency fallback** โ€” controlled by `OMNIROUTE_EMERGENCY_FALLBACK`; operators can override it from the Feature Flags page without a restart. --- diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b51e645e6a7..c2f98d23d9a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -874,7 +874,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | | `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | | `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | -| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. | +| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. | | `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | | `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | | `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | diff --git a/open-sse/services/emergencyFallback.ts b/open-sse/services/emergencyFallback.ts index 3819848291e..60e3914172a 100644 --- a/open-sse/services/emergencyFallback.ts +++ b/open-sse/services/emergencyFallback.ts @@ -12,6 +12,16 @@ * `OMNIROUTE_EMERGENCY_FALLBACK=false` (or `0`). Default remains enabled. */ +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +const EMERGENCY_FALLBACK_FLAG_KEY = "OMNIROUTE_EMERGENCY_FALLBACK"; +const EMERGENCY_FALLBACK_FLAG_CACHE_MS = 500; + +type FeatureFlagResolver = (key: string) => boolean; + +let emergencyFallbackFlagCache: { value: boolean; expiresAt: number } | null = null; +let emergencyFallbackFeatureFlagResolver: FeatureFlagResolver = isFeatureFlagEnabled; + export interface EmergencyFallbackConfig { enabled: boolean; provider: string; @@ -66,11 +76,46 @@ export interface NoFallbackDecision { export type FallbackResult = FallbackDecision | NoFallbackDecision; -export function isEmergencyFallbackEnvEnabled(): boolean { +function isEmergencyFallbackRawEnvEnabled(): boolean { const raw = process.env.OMNIROUTE_EMERGENCY_FALLBACK; return raw !== "false" && raw !== "0"; } +export function resetEmergencyFallbackEnvCache(): void { + emergencyFallbackFlagCache = null; +} + +export function setEmergencyFallbackFeatureFlagResolverForTest( + resolver: FeatureFlagResolver | null +): void { + emergencyFallbackFeatureFlagResolver = resolver ?? isFeatureFlagEnabled; + resetEmergencyFallbackEnvCache(); +} + +export function isEmergencyFallbackEnvEnabled(): boolean { + const now = Date.now(); + if (emergencyFallbackFlagCache && emergencyFallbackFlagCache.expiresAt > now) { + return emergencyFallbackFlagCache.value; + } + + let value: boolean; + try { + value = emergencyFallbackFeatureFlagResolver(EMERGENCY_FALLBACK_FLAG_KEY); + } catch (error) { + console.warn( + "[emergencyFallback] Feature flag resolution failed; falling back to raw env:", + error instanceof Error ? error.message : error + ); + value = isEmergencyFallbackRawEnvEnabled(); + } + + emergencyFallbackFlagCache = { + value, + expiresAt: now + EMERGENCY_FALLBACK_FLAG_CACHE_MS, + }; + return value; +} + export function shouldUseFallback( status: number, errorBody: string, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2de9742fc4e..88400249c63 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -842,6 +842,7 @@ "batchActionRetryError": "Failed to retry failed requests. Try again.", "batchConceptRetentionNote": "Results and error files are retained for 30 days (Anthropic: 29 days)" }, + "featureFlagOmnirouteEmergencyFallbackDescription": "Route budget-exhausted requests to the emergency free fallback provider/model.", "sidebar": { "home": "Home", "dashboard": "Dashboard", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b404d7831ce..06e0efe342d 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -199,7 +199,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ warningLevel: "info", }, - // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Runtime (8) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Runtime (9) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ { key: "OMNIROUTE_MCP_ENFORCE_SCOPES", label: "MCP Enforce Scopes", @@ -279,6 +279,17 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "OMNIROUTE_EMERGENCY_FALLBACK", + label: "Emergency Fallback", + description: "Route budget-exhausted requests to the emergency free fallback provider/model.", + descriptionI18nKey: "featureFlagOmnirouteEmergencyFallbackDescription", + category: "runtime", + defaultValue: "true", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MODEL_CATALOG_INCLUDE_NAMES", label: "Model Catalog Names", diff --git a/tests/unit/emergency-fallback-service.test.ts b/tests/unit/emergency-fallback-service.test.ts index 45931c54f4b..b229fb70122 100644 --- a/tests/unit/emergency-fallback-service.test.ts +++ b/tests/unit/emergency-fallback-service.test.ts @@ -1,8 +1,55 @@ 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 { EMERGENCY_FALLBACK_CONFIG, shouldUseFallback, isFallbackDecision } = - await import("../../open-sse/services/emergencyFallback.ts"); +const previousDataDir = process.env.DATA_DIR; +const previousDisableSqliteAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP; +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-emergency-fallback-service-")); +process.env.DATA_DIR = tmpDir; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); + +const { + EMERGENCY_FALLBACK_CONFIG, + shouldUseFallback, + isFallbackDecision, + resetEmergencyFallbackEnvCache, +} = await import("../../open-sse/services/emergencyFallback.ts"); + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function resetTestState() { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + resetEmergencyFallbackEnvCache(); +} + +test.beforeEach(() => { + resetTestState(); +}); + +test.afterEach(() => { + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + resetEmergencyFallbackEnvCache(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + restoreEnv("DATA_DIR", previousDataDir); + restoreEnv("DISABLE_SQLITE_AUTO_BACKUP", previousDisableSqliteAutoBackup); +}); test("shouldUseFallback returns disabled when the feature flag is off", () => { const result = shouldUseFallback(402, "payment required", false, { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 9a3ccc424a5..83ed2278825 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -32,13 +32,13 @@ const { // Test group 1 โ€” Flag definitions registry // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ describe("featureFlagDefinitions", () => { - it("has exactly 30 flag definitions", () => { - assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 30); + it("has exactly 31 flag definitions", () => { + assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 31); }); it("has unique keys for all flags", () => { const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key); - assert.strictEqual(new Set(keys).size, 30); + assert.strictEqual(new Set(keys).size, 31); }); it("has valid categories for all flags", () => { @@ -95,6 +95,15 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.defaultValue, "true"); assert.strictEqual(def.requiresRestart, false); }); + + it("defines emergency fallback as a runtime boolean flag enabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "OMNIROUTE_EMERGENCY_FALLBACK"); + assert.ok(def, "OMNIROUTE_EMERGENCY_FALLBACK should exist"); + assert.strictEqual(def.category, "runtime"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "true"); + assert.strictEqual(def.requiresRestart, false); + }); }); // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -232,9 +241,9 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 30 flags", () => { + it("returns all 31 flags", () => { const all = resolveAllFeatureFlags(); - assert.strictEqual(all.length, 30); + assert.strictEqual(all.length, 31); }); it("marks DB-overridden flags with source 'db'", () => { diff --git a/tests/unit/services/emergency-fallback.test.ts b/tests/unit/services/emergency-fallback.test.ts index bf43dd5d59f..bf6c39603b3 100644 --- a/tests/unit/services/emergency-fallback.test.ts +++ b/tests/unit/services/emergency-fallback.test.ts @@ -1,10 +1,59 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const previousDataDir = process.env.DATA_DIR; +const previousDisableSqliteAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP; +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-emergency-fallback-")); +process.env.DATA_DIR = tmpDir; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const { setFeatureFlagOverride, removeFeatureFlagOverride } = + await import("../../../src/lib/db/featureFlags.ts"); +const { shouldUseFallback, isEmergencyFallbackEnvEnabled, EMERGENCY_FALLBACK_CONFIG, -} from "../../../open-sse/services/emergencyFallback.ts"; + resetEmergencyFallbackEnvCache, + setEmergencyFallbackFeatureFlagResolverForTest, +} = await import("../../../open-sse/services/emergencyFallback.ts"); + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function resetTestState() { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + resetEmergencyFallbackEnvCache(); + setEmergencyFallbackFeatureFlagResolverForTest(null); +} + +test.beforeEach(() => { + resetTestState(); +}); + +test.afterEach(() => { + resetEmergencyFallbackEnvCache(); + setEmergencyFallbackFeatureFlagResolverForTest(null); + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + restoreEnv("DATA_DIR", previousDataDir); + restoreEnv("DISABLE_SQLITE_AUTO_BACKUP", previousDisableSqliteAutoBackup); +}); function withEnv(value: string | undefined, fn: () => void) { const previous = process.env.OMNIROUTE_EMERGENCY_FALLBACK; @@ -13,14 +62,23 @@ function withEnv(value: string | undefined, fn: () => void) { } else { process.env.OMNIROUTE_EMERGENCY_FALLBACK = value; } + resetEmergencyFallbackEnvCache(); try { fn(); } finally { - if (previous === undefined) { - delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; - } else { - process.env.OMNIROUTE_EMERGENCY_FALLBACK = previous; - } + restoreEnv("OMNIROUTE_EMERGENCY_FALLBACK", previous); + resetEmergencyFallbackEnvCache(); + } +} + +function withFeatureFlagOverride(value: string, fn: () => void) { + try { + setFeatureFlagOverride("OMNIROUTE_EMERGENCY_FALLBACK", value); + resetEmergencyFallbackEnvCache(); + fn(); + } finally { + removeFeatureFlagOverride("OMNIROUTE_EMERGENCY_FALLBACK"); + resetEmergencyFallbackEnvCache(); } } @@ -60,6 +118,51 @@ test("OMNIROUTE_EMERGENCY_FALLBACK=0 disables the budget-keyword redirect", () = }); }); +test("DB feature flag override can disable an env-enabled fallback", () => { + withEnv("true", () => { + withFeatureFlagOverride("false", () => { + const decision = shouldUseFallback(402, "", false); + assert.equal(isEmergencyFallbackEnvEnabled(), false); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/); + }); + }); +}); + +test("DB feature flag override can enable an env-disabled fallback", () => { + withEnv("false", () => { + withFeatureFlagOverride("true", () => { + const decision = shouldUseFallback(402, "", false); + assert.equal(isEmergencyFallbackEnvEnabled(), true); + assert.equal(decision.shouldFallback, true); + }); + }); +}); + +test("raw env fallback is used when feature flag resolution throws", () => { + withEnv("0", () => { + const warnings: unknown[][] = []; + const previousWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + setEmergencyFallbackFeatureFlagResolverForTest(() => { + throw new Error("feature flag store unavailable"); + }); + + try { + assert.equal(isEmergencyFallbackEnvEnabled(), false); + const decision = shouldUseFallback(402, "", false); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/); + assert.equal(warnings.length, 1); + assert.match(String(warnings[0]?.[0]), /Feature flag resolution failed/); + } finally { + console.warn = previousWarn; + } + }); +}); + test("explicit truthy values keep the fallback enabled", () => { withEnv("true", () => { assert.equal(isEmergencyFallbackEnvEnabled(), true); From 15ab18381020af9a4de1124a75457c6fd6f517bb Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:42:43 -0400 Subject: [PATCH 05/36] Fix Request Logs clean history purge (#3751) Make the Request Logs Clean history action a full request-history purge instead of retention-only: clears call_logs, legacy request_detail_logs, and local JSON artifacts under DATA_DIR/call_logs (including orphans), via a dedicated POST /api/settings/purge-request-history route. Also hardens the legacy purge-call-logs route to route errors through buildErrorBody (Rule #12). Integrated into release/v3.8.24. --- docs/getting-started/TROUBLESHOOTING.md | 3 + docs/guides/TROUBLESHOOTING.md | 3 + docs/reference/API_REFERENCE.md | 19 ++--- docs/reference/openapi.yaml | 43 +++++++++++ docs/security/COMPLIANCE.md | 5 ++ src/app/(dashboard)/dashboard/logs/page.tsx | 67 ++++++++++++----- src/app/api/settings/purge-call-logs/route.ts | 11 ++- .../settings/purge-request-history/route.ts | 32 +++++++++ src/i18n/messages/en.json | 8 +++ src/lib/db/cleanup.ts | 15 +++- src/lib/usage/callLogArtifacts.ts | 28 ++++++++ src/lib/usage/callLogs.ts | 8 +-- .../database-settings-maintenance.test.ts | 72 +++++++++++++++++++ 13 files changed, 278 insertions(+), 36 deletions(-) create mode 100644 src/app/api/settings/purge-request-history/route.ts diff --git a/docs/getting-started/TROUBLESHOOTING.md b/docs/getting-started/TROUBLESHOOTING.md index 04fcb25f746..1f79f72b20c 100644 --- a/docs/getting-started/TROUBLESHOOTING.md +++ b/docs/getting-started/TROUBLESHOOTING.md @@ -259,6 +259,9 @@ curl http://localhost:20128/api/monitoring/health - Application logs: `/logs/...` (when `APP_LOG_TO_FILE=true`) - Call log artifacts: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` when the call log pipeline is enabled +The Request Logs page's **Clean history** action clears `call_logs`, legacy +`request_detail_logs`, and the local `${DATA_DIR}/call_logs/` artifact directory. + --- ## Circuit Breaker Issues diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 01e197fe07e..fc501b73e44 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -263,6 +263,9 @@ curl http://localhost:20128/api/monitoring/health - Application logs: `/logs/...` (when `APP_LOG_TO_FILE=true`) - Call log artifacts: `${DATA_DIR}/call_logs/YYYY-MM-DD/...` when the call log pipeline is enabled +The Request Logs page's **Clean history** action clears `call_logs`, legacy +`request_detail_logs`, and the local `${DATA_DIR}/call_logs/` artifact directory. + --- ## Circuit Breaker Issues diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 257f5f20449..2e738f99fb4 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -404,15 +404,16 @@ Response example: ### Settings -| Endpoint | Method | Description | -| ------------------------------- | ------------- | ------------------------- | -| `/api/settings` | GET/PUT/PATCH | General settings | -| `/api/settings/proxy` | GET/PUT | Network proxy config | -| `/api/settings/proxy/test` | POST | Test proxy connection | -| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | -| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | -| `/api/settings/system-prompt` | GET/PUT | Global system prompt | -| `/api/settings/compression` | GET/PUT | Global compression config | +| Endpoint | Method | Description | +| ------------------------------------- | ------------- | --------------------------------------------------- | +| `/api/settings` | GET/PUT/PATCH | General settings | +| `/api/settings/proxy` | GET/PUT | Network proxy config | +| `/api/settings/proxy/test` | POST | Test proxy connection | +| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | +| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | +| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| `/api/settings/compression` | GET/PUT | Global compression config | +| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts | ### Context & Compression diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 81eeaa60a48..debb43ec32a 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1803,6 +1803,49 @@ paths: "200": description: Updated settings + /api/settings/purge-request-history: + post: + tags: [Settings] + summary: Clear request log history + description: Deletes `call_logs`, legacy `request_detail_logs`, and local request artifact files under `DATA_DIR/call_logs`. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Request history cleared + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + deletedArtifacts: + type: integer + deletedDetailedLogs: + type: integer + errors: + type: integer + "401": + description: Unauthorized + "500": + description: Cleanup failed or reported errors + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + deletedArtifacts: + type: integer + deletedDetailedLogs: + type: integer + errors: + type: integer + error: + type: object + /api/settings/compression: get: tags: [Compression] diff --git a/docs/security/COMPLIANCE.md b/docs/security/COMPLIANCE.md index c8c4eef8394..1a3d9f599ee 100644 --- a/docs/security/COMPLIANCE.md +++ b/docs/security/COMPLIANCE.md @@ -118,6 +118,11 @@ from `src/server-init.ts` and `src/instrumentation-node.ts`. Each run logs a `compliance.cleanup` audit event with the per-table delete counts. Proxy/call log trimming is batched (`BATCH_SIZE = 5000`) to avoid long write locks. +Manual request-history cleanup is separate from retention. The Request Logs +page calls `POST /api/settings/purge-request-history`, which deletes `call_logs`, +legacy `request_detail_logs`, and local request artifacts under +`${DATA_DIR}/call_logs/`. + Defaults are defined in `src/lib/logEnv.ts` (`DEFAULT_APP_LOG_RETENTION_DAYS = 7`, `DEFAULT_CALL_LOG_RETENTION_DAYS = 7`). diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index da13c5a770f..1fd393f83c3 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -12,6 +12,20 @@ const TIME_RANGES = [ { label: "24h", hours: 24 }, ]; +type LogsTranslator = ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; +}; + +function logsText( + t: LogsTranslator, + key: string, + fallback: string, + values?: Record +) { + if (typeof t.has !== "function" || !t.has(key)) return fallback; + return values ? t(key, values) : t(key); +} + export default function LogsPage() { const [showExport, setShowExport] = useState(false); const [exporting, setExporting] = useState(false); @@ -21,7 +35,7 @@ export default function LogsPage() { const [requestLogKey, setRequestLogKey] = useState(0); const dropdownRef = useRef(null); const requestLoggerRef = useRef(null); - const t = useTranslations("logs"); + const t = useTranslations("logs") as LogsTranslator; useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -34,7 +48,8 @@ export default function LogsPage() { }, []); // initial id from URL (synchronously on client) so child can open on mount - const initialId = typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; + const initialId = + typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; async function handleExport(hours: number) { setExporting(true); @@ -64,26 +79,40 @@ export default function LogsPage() { setShowCleanHistory(false); setCleanHistoryStatus(null); try { - const res = await fetch("/api/settings/purge-logs", { method: "POST" }); + const res = await fetch("/api/settings/purge-request-history", { method: "POST" }); const data = await res.json().catch(() => null); if (!res.ok) { - throw new Error(data?.error || "Failed to clean log history."); + throw new Error( + data?.error || logsText(t, "cleanHistoryFailed", "Failed to clean log history.") + ); } const deleted = typeof data?.deleted === "number" ? data.deleted : 0; - const deletedArtifacts = typeof data?.deletedArtifacts === "number" ? data.deletedArtifacts : 0; + const deletedArtifacts = + typeof data?.deletedArtifacts === "number" ? data.deletedArtifacts : 0; + const deletedDetailedLogs = + typeof data?.deletedDetailedLogs === "number" ? data.deletedDetailedLogs : 0; + const successFallback = `Cleaned ${deleted} log entr${deleted === 1 ? "y" : "ies"}, ${deletedArtifacts} artifact${ + deletedArtifacts === 1 ? "" : "s" + }, and ${deletedDetailedLogs} legacy detail row${deletedDetailedLogs === 1 ? "" : "s"}.`; setRequestLogKey((key) => key + 1); setCleanHistoryStatus( - deleted || deletedArtifacts - ? `Cleaned ${deleted} log entr${deleted === 1 ? "y" : "ies"} and ${deletedArtifacts} artifact${ - deletedArtifacts === 1 ? "" : "s" - }.` - : "No expired log history needed cleanup." + deleted || deletedArtifacts || deletedDetailedLogs + ? logsText(t, "cleanHistorySuccess", successFallback, { + deleted, + deletedArtifacts, + deletedDetailedLogs, + }) + : logsText(t, "cleanHistoryEmpty", "No request log history was found.") ); } catch (err) { - console.error("Failed to clean log history", err); - setCleanHistoryStatus(err instanceof Error ? err.message : "Failed to clean log history."); + console.error(logsText(t, "cleanHistoryFailed", "Failed to clean log history."), err); + setCleanHistoryStatus( + err instanceof Error + ? err.message + : logsText(t, "cleanHistoryFailed", "Failed to clean log history.") + ); } finally { setCleaningHistory(false); } @@ -120,7 +149,7 @@ export default function LogsPage() { strokeLinejoin="round" /> - Clean history + {logsText(t, "cleanHistoryButton", "Clean history")}
@@ -195,10 +224,14 @@ export default function LogsPage() { isOpen={showCleanHistory} onClose={() => setShowCleanHistory(false)} onConfirm={handleCleanHistory} - title="Clean log history?" - message="This clears expired log history and prunes related artifacts using the current retention policy. The live page will refresh after cleanup." - confirmText="Clean history" - cancelText="Cancel" + title={logsText(t, "cleanHistoryTitle", "Clean log history?")} + message={logsText( + t, + "cleanHistoryMessage", + "This permanently clears all request log rows, legacy detail rows, and local artifact files under DATA_DIR/call_logs. The live page will refresh after cleanup." + )} + confirmText={logsText(t, "cleanHistoryConfirm", "Clean history")} + cancelText={logsText(t, "cleanHistoryCancel", "Cancel")} loading={cleaningHistory} />
diff --git a/src/app/api/settings/purge-call-logs/route.ts b/src/app/api/settings/purge-call-logs/route.ts index 2e5c21d6db7..96e1842fd46 100644 --- a/src/app/api/settings/purge-call-logs/route.ts +++ b/src/app/api/settings/purge-call-logs/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; import { purgeCallLogs } from "@/lib/db/cleanup"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +export const runtime = "nodejs"; + export async function POST(request: Request) { if (!(await isAuthenticated(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -10,10 +13,12 @@ export async function POST(request: Request) { const result = await purgeCallLogs(); return NextResponse.json({ deleted: result.deleted, + deletedArtifacts: result.deletedArtifacts ?? 0, errors: result.errors, }); - } catch (err: unknown) { - const error = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error }, { status: 500 }); + } catch { + return NextResponse.json(buildErrorBody(500, "Failed to purge call logs"), { + status: 500, + }); } } diff --git a/src/app/api/settings/purge-request-history/route.ts b/src/app/api/settings/purge-request-history/route.ts new file mode 100644 index 00000000000..66828060572 --- /dev/null +++ b/src/app/api/settings/purge-request-history/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { purgeCallLogs, purgeDetailedLogs } from "@/lib/db/cleanup"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const callLogs = await purgeCallLogs(); + const detailedLogs = await purgeDetailedLogs(); + const errors = callLogs.errors + detailedLogs.errors; + + return NextResponse.json( + { + deleted: callLogs.deleted, + deletedArtifacts: callLogs.deletedArtifacts ?? 0, + deletedDetailedLogs: detailedLogs.deleted, + errors, + }, + { status: errors > 0 ? 500 : 200 } + ); + } catch { + return NextResponse.json(buildErrorBody(500, "Failed to purge request history"), { + status: 500, + }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 88400249c63..94cabf491e8 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3771,6 +3771,14 @@ "export": "Export", "exporting": "Exporting...", "exportFailed": "Export failed", + "cleanHistoryButton": "Clean history", + "cleanHistoryTitle": "Clean log history?", + "cleanHistoryMessage": "This permanently clears all request log rows, legacy detail rows, and local artifact files under DATA_DIR/call_logs. The live page will refresh after cleanup.", + "cleanHistoryConfirm": "Clean history", + "cleanHistoryCancel": "Cancel", + "cleanHistorySuccess": "Cleaned {deleted} log entries, {deletedArtifacts} artifacts, and {deletedDetailedLogs} legacy detail rows.", + "cleanHistoryEmpty": "No request log history was found.", + "cleanHistoryFailed": "Failed to clean log history.", "timeRange": "Time Range", "lastNHours": "Last {hours}", "defaultRange": "default", diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 9d67fb91516..1772240fdf5 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -7,9 +7,11 @@ import { getDbInstance } from "./core"; import { getUserDatabaseSettings } from "./databaseSettings"; import { rollupUsageHistoryBeforeDate } from "@/lib/usage/aggregateHistory"; +import { purgeCallLogArtifactDirectory } from "@/lib/usage/callLogArtifacts"; interface CleanupResult { deleted: number; + deletedArtifacts?: number; errors: number; } @@ -304,11 +306,10 @@ export async function purgeQuotaSnapshots(): Promise { */ export async function purgeCallLogs(): Promise { const db = getDbInstance(); - const result: CleanupResult = { deleted: 0, errors: 0 }; + const result: CleanupResult = { deleted: 0, deletedArtifacts: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM call_logs"); - const runResult = stmt.run(); + const runResult = db.prepare("DELETE FROM call_logs").run(); result.deleted = runResult.changes; console.log(`[Cleanup] Purged ${result.deleted} call_logs`); @@ -317,6 +318,14 @@ export async function purgeCallLogs(): Promise { result.errors++; } + const artifactResult = purgeCallLogArtifactDirectory(); + result.deletedArtifacts = artifactResult.deletedArtifacts; + result.errors += artifactResult.errors; + + if (artifactResult.errors === 0) { + console.log(`[Cleanup] Purged ${result.deletedArtifacts} call log artifact(s)`); + } + return result; } diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts index 96f8dd45425..e1e1913571f 100644 --- a/src/lib/usage/callLogArtifacts.ts +++ b/src/lib/usage/callLogArtifacts.ts @@ -61,6 +61,11 @@ export type CallLogArtifactWriteResult = { sha256: string; }; +export type PurgeCallLogArtifactDirectoryResult = { + deletedArtifacts: number; + errors: number; +}; + export function buildArtifactRelativePath(timestamp: string, id: string) { const parsed = new Date(timestamp); const safeTimestamp = ( @@ -307,3 +312,26 @@ export function listCallLogArtifactFiles(baseDir = CALL_LOGS_DIR) { }) .sort((a, b) => b.mtimeMs - a.mtimeMs); } + +export function purgeCallLogArtifactDirectory( + baseDir = CALL_LOGS_DIR +): PurgeCallLogArtifactDirectoryResult { + const result = { deletedArtifacts: 0, errors: 0 }; + if (!baseDir || !fs.existsSync(baseDir)) return result; + + try { + result.deletedArtifacts = listCallLogArtifactFiles(baseDir).length; + } catch { + result.deletedArtifacts = 0; + } + + try { + fs.rmSync(baseDir, { recursive: true, force: true }); + } catch (error) { + console.error("[callLogArtifacts] Failed to purge call log artifacts:", error); + result.deletedArtifacts = 0; + result.errors++; + } + + return result; +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index e0096a29ebb..e18bbf140a1 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -899,7 +899,7 @@ export async function getCallLogById(id: string) { error: artifactResult.artifact.error ?? entry.error, pipelinePayloads: artifactResult.artifact.pipeline ?? buildLegacyPipelinePayloads(id), hasPipelineDetails: Boolean(artifactResult.artifact.pipeline) || entry.hasPipelineDetails, - active: false + active: false, }; } @@ -923,7 +923,7 @@ export async function getCallLogById(id: string) { ...legacyInline, pipelinePayloads: legacyPipeline, hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails, - active: false + active: false, }; } } @@ -940,7 +940,7 @@ export async function getCallLogById(id: string) { error: legacyDisk.error ?? entry.error, pipelinePayloads: legacyPipeline, hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails, - active: false + active: false, }; } @@ -954,7 +954,7 @@ export async function getCallLogById(id: string) { error: entry.error, pipelinePayloads: legacyPipeline, hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails, - active: false + active: false, }; } diff --git a/tests/unit/database-settings-maintenance.test.ts b/tests/unit/database-settings-maintenance.test.ts index 449ee5a4eaa..b29ee88430a 100644 --- a/tests/unit/database-settings-maintenance.test.ts +++ b/tests/unit/database-settings-maintenance.test.ts @@ -11,6 +11,8 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; const core = await import("../../src/lib/db/core.ts"); const databaseSettings = await import("../../src/lib/db/databaseSettings.ts"); const databaseSettingsRoute = await import("../../src/app/api/settings/database/route.ts"); +const purgeRequestHistoryRoute = + await import("../../src/app/api/settings/purge-request-history/route.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const cleanup = await import("../../src/lib/db/cleanup.ts"); const aggregateHistory = await import("../../src/lib/usage/aggregateHistory.ts"); @@ -40,6 +42,31 @@ function makeJsonRequest(method: string, body?: unknown): Request { }); } +function insertCallLog(id: string, artifactRelPath: string | null = null) { + const db = core.getDbInstance(); + db.prepare( + ` + INSERT INTO call_logs (id, timestamp, method, path, status, detail_state, artifact_relpath) + VALUES (?, ?, ?, ?, ?, ?, ?) + ` + ).run( + id, + "2026-06-01T12:00:00.000Z", + "POST", + "/v1/chat/completions", + 200, + artifactRelPath ? "ready" : "none", + artifactRelPath + ); +} + +function writeCallLogArtifact(relativePath: string) { + const absolutePath = path.join(TEST_DATA_DIR, "call_logs", relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, JSON.stringify({ relativePath }), "utf8"); + return absolutePath; +} + test.beforeEach(() => { resetStorage(); }); @@ -142,6 +169,51 @@ test("purgeDetailedLogs deletes request_detail_logs", async () => { ); }); +test("purgeCallLogs deletes summary rows and local request artifacts", async () => { + const db = core.getDbInstance(); + const artifactPath = writeCallLogArtifact("2026-06-01/request-1.json"); + const orphanPath = writeCallLogArtifact("2026-06-02/orphan.json"); + insertCallLog("call-1", "2026-06-01/request-1.json"); + + const result = await cleanup.purgeCallLogs(); + + assert.equal(result.errors, 0); + assert.equal(result.deleted, 1); + assert.equal(result.deletedArtifacts, 2); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM call_logs").get() as CountRow).count, 0); + assert.equal(fs.existsSync(artifactPath), false); + assert.equal(fs.existsSync(orphanPath), false); + assert.equal(fs.existsSync(path.join(TEST_DATA_DIR, "call_logs")), false); +}); + +test("purge request history route clears call logs, artifacts, and legacy detail rows", async () => { + const db = core.getDbInstance(); + const artifactPath = writeCallLogArtifact("2026-06-01/request-route.json"); + insertCallLog("call-route", "2026-06-01/request-route.json"); + db.prepare("INSERT INTO request_detail_logs (id, timestamp, duration_ms) VALUES (?, ?, ?)").run( + "detail-route", + "2026-06-01T12:00:01.000Z", + 25 + ); + + const response = await purgeRequestHistoryRoute.POST( + new Request("http://localhost/api/settings/purge-request-history", { method: "POST" }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.deleted, 1); + assert.equal(body.deletedArtifacts, 1); + assert.equal(body.deletedDetailedLogs, 1); + assert.equal(body.errors, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM call_logs").get() as CountRow).count, 0); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM request_detail_logs").get() as CountRow).count, + 0 + ); + assert.equal(fs.existsSync(artifactPath), false); +}); + test("usage aggregation upserts replace recomputed totals instead of adding them twice", async () => { const db = core.getDbInstance(); const insertSnapshot = db.prepare( From 01b1dab3304985c2c89fd819dc1a6aa6e425655e Mon Sep 17 00:00:00 2001 From: kkkayye <98376609+kkkayye@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:49:39 +0900 Subject: [PATCH 06/36] Inject memory into Codex Responses WebSocket requests (#3749) Inject retrieved OmniRoute memory into Codex Responses-over-WebSocket prepare requests via the instructions field, deriving the retrieval query from the latest user Responses input while skipping tool/reasoning payloads, with a duplicate-safe injection guard. The Responses-WS request-history logging this branch also carried already landed on release; the net change here is the memory-injection parity with the SSE path. Integrated into release/v3.8.24. Co-authored-by: diegosouzapw --- .../api/internal/codex-responses-ws/route.ts | 163 +++++++++++++++++- tests/unit/codex-responses-ws-memory.test.ts | 76 ++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 tests/unit/codex-responses-ws-memory.test.ts diff --git a/src/app/api/internal/codex-responses-ws/route.ts b/src/app/api/internal/codex-responses-ws/route.ts index 5bc72d44ca0..0e687644440 100644 --- a/src/app/api/internal/codex-responses-ws/route.ts +++ b/src/app/api/internal/codex-responses-ws/route.ts @@ -9,12 +9,22 @@ import { getProviderCredentialsWithQuotaPreflight } from "@/sse/services/auth"; import { checkAndRefreshToken } from "@/sse/services/tokenRefresh"; import { resolveCodexWsModelInfo } from "./modelResolution"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { formatMemoryContext } from "@/lib/memory/injection"; +import { retrieveMemories } from "@/lib/memory/retrieval"; +import { + DEFAULT_MEMORY_SETTINGS, + getMemorySettings, + toMemoryRetrievalConfig, +} from "@/lib/memory/settings"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { logger } from "@omniroute/open-sse/utils/logger.ts"; const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses"; const executor = new CodexExecutor(); +const log = logger("RESPONSES_WS"); type JsonRecord = Record; +type ApiKeyMetadata = Awaited>; const bridgePayloadSchema = z .object({ @@ -33,6 +43,156 @@ function toStringOrNull(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +const RESPONSES_WS_MEMORY_CONTEXT_PREFIX = "Memory context:"; +const RESPONSES_WS_MEMORY_TEXT_PART_TYPES = new Set(["text", "input_text", "output_text"]); +const RESPONSES_WS_MEMORY_SKIP_ITEM_TYPES = new Set([ + "function_call", + "function_call_output", + "tool_call", + "tool_call_output", + "reasoning", + "computer_call", + "computer_call_output", + "web_search_call", + "file_search_call", +]); + +function compactText(parts: Array): string | null { + const text = parts + .map((part) => (typeof part === "string" ? part.trim() : "")) + .filter(Boolean) + .join("\n"); + return text.length > 0 ? text : null; +} + +function extractResponsesWsContentText(value: unknown): string | null { + if (typeof value === "string") return toStringOrNull(value); + + if (Array.isArray(value)) { + return compactText( + value.map((part) => { + if (typeof part === "string") return toStringOrNull(part); + if (!isRecord(part)) return null; + + const type = typeof part.type === "string" ? part.type : ""; + if (type && !RESPONSES_WS_MEMORY_TEXT_PART_TYPES.has(type)) return null; + + return toStringOrNull(part.text) || toStringOrNull(part.input_text); + }) + ); + } + + if (isRecord(value)) { + const type = typeof value.type === "string" ? value.type : ""; + if (type && !RESPONSES_WS_MEMORY_TEXT_PART_TYPES.has(type)) return null; + return toStringOrNull(value.text) || toStringOrNull(value.input_text); + } + + return null; +} + +function extractResponsesWsItemText(value: unknown): string | null { + if (typeof value === "string") return toStringOrNull(value); + if (!isRecord(value)) return null; + + return ( + extractResponsesWsContentText(value.content) || + extractResponsesWsContentText(value.text) || + extractResponsesWsContentText(value.input_text) || + extractResponsesWsContentText(value.output_text) || + extractResponsesWsContentText(value.output) + ); +} + +function isResponsesWsMemoryCandidate(value: unknown): boolean { + if (!isRecord(value)) return typeof value === "string"; + const type = typeof value.type === "string" ? value.type : ""; + return !RESPONSES_WS_MEMORY_SKIP_ITEM_TYPES.has(type); +} + +function extractLatestResponsesWsInputText(input: unknown): string | null { + if (typeof input === "string") return toStringOrNull(input); + if (!Array.isArray(input)) return null; + + for (let index = input.length - 1; index >= 0; index -= 1) { + const item = input[index]; + if (!isResponsesWsMemoryCandidate(item) || !isRecord(item) || item.role !== "user") continue; + const text = extractResponsesWsItemText(item); + if (text) return text; + } + + for (let index = input.length - 1; index >= 0; index -= 1) { + const item = input[index]; + if (!isResponsesWsMemoryCandidate(item)) continue; + const text = extractResponsesWsItemText(item); + if (text) return text; + } + + return null; +} + +export function extractResponsesWsMemoryQuery(body: JsonRecord): string { + return ( + extractLatestResponsesWsInputText(body.input) || + extractLatestResponsesWsInputText(body.messages) || + toStringOrNull(body.prompt) || + toStringOrNull(body.instructions) || + "" + ); +} + +export function injectResponsesWsMemoryInstructions( + body: JsonRecord, + memoryText: string +): JsonRecord { + const memoryContext = toStringOrNull(memoryText); + if (!memoryContext) return body; + + const existingInstructions = toStringOrNull(body.instructions); + if (existingInstructions?.includes(RESPONSES_WS_MEMORY_CONTEXT_PREFIX)) return body; + + return { + ...body, + instructions: [memoryContext, existingInstructions].filter(Boolean).join("\n\n"), + }; +} + +async function getMemorySettingsForResponsesWs() { + try { + return await getMemorySettings(); + } catch (error) { + log.warn("memory.settings.defaulted", { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + }); + return DEFAULT_MEMORY_SETTINGS; + } +} + +async function maybeInjectResponsesWsMemory( + responseBody: JsonRecord, + metadata: ApiKeyMetadata | null +): Promise { + if (!metadata?.id) return responseBody; + + const query = extractResponsesWsMemoryQuery(responseBody); + if (!query) return responseBody; + + try { + const memorySettings = await getMemorySettingsForResponsesWs(); + const memories = await retrieveMemories( + metadata.id, + toMemoryRetrievalConfig(memorySettings, { query }) + ); + const memoryText = formatMemoryContext(memories); + return injectResponsesWsMemoryInstructions(responseBody, memoryText); + } catch (error) { + log.warn("memory.injection.skipped", { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + }); + return responseBody; + } +} + function toFiniteNumber(value: unknown, fallback = 0): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; @@ -235,9 +395,10 @@ async function prepare(body: JsonRecord) { return jsonError(401, "codex_oauth_token_missing", "Codex OAuth access token is missing"); } + const responseBodyWithMemory = await maybeInjectResponsesWsMemory(responseBody, metadata); const transformed = (await executor.transformRequest( model, - responseBody, + responseBodyWithMemory, true, refreshedCredentials )) as JsonRecord; diff --git a/tests/unit/codex-responses-ws-memory.test.ts b/tests/unit/codex-responses-ws-memory.test.ts new file mode 100644 index 00000000000..00f06dcacd8 --- /dev/null +++ b/tests/unit/codex-responses-ws-memory.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + extractResponsesWsMemoryQuery, + injectResponsesWsMemoryInstructions, +} from "../../src/app/api/internal/codex-responses-ws/route.ts"; + +test("Responses WS memory query uses the latest user text and skips tool/reasoning items", () => { + const query = extractResponsesWsMemoryQuery({ + input: [ + { + role: "user", + content: [{ type: "input_text", text: "old question" }], + }, + { + type: "reasoning", + content: [{ type: "output_text", text: "do not retrieve from this" }], + }, + { + type: "function_call_output", + output: "do not retrieve from tool output", + }, + { + role: "user", + content: [ + { type: "input_text", text: "latest question" }, + { type: "input_text", text: "with detail" }, + ], + }, + ], + instructions: "fallback instructions", + }); + + assert.equal(query, "latest question\nwith detail"); +}); + +test("Responses WS memory query falls back to prompt or instructions", () => { + assert.equal(extractResponsesWsMemoryQuery({ prompt: " prompt text " }), "prompt text"); + assert.equal( + extractResponsesWsMemoryQuery({ instructions: " instruction text " }), + "instruction text" + ); +}); + +test("Responses WS memory injection prepends memory to instructions without mutating input", () => { + const request = { + model: "gpt-5.5", + instructions: "follow the user request", + input: "hello", + }; + + const result = injectResponsesWsMemoryInstructions( + request, + "Memory context: user prefers concise replies" + ); + + assert.notEqual(result, request); + assert.equal(request.instructions, "follow the user request"); + assert.equal( + result.instructions, + "Memory context: user prefers concise replies\n\nfollow the user request" + ); +}); + +test("Responses WS memory injection does not duplicate an existing memory block", () => { + const request = { + model: "gpt-5.5", + instructions: "Memory context: existing\n\nfollow the user request", + }; + + const result = injectResponsesWsMemoryInstructions(request, "Memory context: duplicate"); + + assert.equal(result, request); + assert.equal(result.instructions, "Memory context: existing\n\nfollow the user request"); +}); From 37116fd4d6fef68340b6293150d11012463a49d0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:01:09 -0300 Subject: [PATCH 07/36] fix(antigravity): skip credits retry on full_quota_exhausted, persist 24h cooldown to DB (#3707) (#3754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs fixed together: 1. decide429() was called AFTER the Google One AI credits retry โ€” a quota-exhausted account could hang ~41s on the credits HTTP call before the full_quota_exhausted verdict was ever computed. Now decide429() runs first; credits retry is skipped when kind === full_quota_exhausted. 2. setConnectionRateLimitUntil() was never called from the antigravity executor โ€” the 24h cooldown state lived only in memory and was lost on restart, causing post-restart requests to re-learn exhaustion the hard way (7,412 upstream 429s documented in #3707). markConnectionQuotaExhausted() now persists it to the DB. 3. antigravity429Engine classify429() did not recognise the real Antigravity quota message ("Individual quota reached. Contact your administrator to enable overages.") โ€” it fell through to the unknown category and never triggered the quota_exhausted path. Added "quota reached", "enable overages", "individual quota" to the keywords. --- CHANGELOG.md | 2 + open-sse/executors/antigravity.ts | 41 +++++--- open-sse/services/antigravity429Engine.ts | 9 +- .../antigravity-429-quota-cooldown.test.ts | 98 +++++++++++++++++++ 4 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 tests/unit/antigravity-429-quota-cooldown.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 808fd81e6f6..09349db1c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### ๐Ÿ› Fixed +- fix(antigravity): skip Google One AI credits retry on `full_quota_exhausted` verdict โ€” antigravity executor now calls `decide429()` before attempting the credits retry so that a quota-exhausted account (24h cooldown) bypasses the extra upstream HTTP call instead of hanging for up to ~41s. Also persists the cooldown in the DB via `setConnectionRateLimitUntil` so post-restart routing skips exhausted connections without re-learning the hard way. Bonus: `antigravity429Engine` now recognises the real Antigravity "Individual quota reached. Contact your administrator to enable overages." error message as `quota_exhausted`. ([#3707](https://github.com/diegosouzapw/OmniRoute/issues/3707) โ€” thanks @andrea-kingautomation) + - fix(cli): `ServerSupervisor.handleExit` now coerces the exit code to a number before calling `process.exit()` โ€” Node.js v24 throws `TypeError [ERR_INVALID_ARG_TYPE]` when `process.exit()` receives a string (e.g. `'ENOENT'` from a spawn `error` event's `err.code`). The `error` callback also now passes `-1` instead of the raw `err.code`, which is an OS error string rather than a meaningful exit code. ([#3748](https://github.com/diegosouzapw/OmniRoute/issues/3748)) --- diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index cd0f50fe509..d55a9162b9e 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -30,6 +30,7 @@ import { handleCreditsFailure, } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; +import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { getMitmAlias } from "@/lib/db/models"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; @@ -328,6 +329,19 @@ function markCreditsExhausted(accountId: string): void { creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS); } +/** + * Persist a quota-exhausted cooldown to the DB for `connectionId` so that + * cross-request and post-restart routing skips this connection until the + * cooldown expires. Exported for unit testing. @internal + */ +export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void { + try { + setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs); + } catch { + // DB write failure must never crash the request path + } +} + /** * Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit * tests (the markdown / candidate-parts extraction branches). @internal @@ -1183,10 +1197,21 @@ export class AntigravityExecutor extends BaseExecutor { const effectiveRetryHintMs = retryMs ?? parsedRetryMs ?? null; const category = classify429(errorMessage); - // 3. For quota_exhausted, attempt Google One AI credits retry FIRST! - // Skip if credits were already injected on the first call - // (creditsMode === "always") โ€” no point re-running with the - // same body. Record the failure so the 5h breaker kicks in. + // 3. Decide final retry time BEFORE the credits retry so that + // full_quota_exhausted can skip the credits attempt entirely + // (avoids ~41s hold on an already-exhausted account) and + // persist the cooldown to DB for post-restart routing. + const decision: Decision = decide429(category, parsedRetryMs); + retryMs = decision.retryAfterMs; + log?.debug?.( + "AG_429", + `Category: ${category}, Decision: ${decision.kind} โ€” ${decision.reason}` + ); + + if (decision.kind === "full_quota_exhausted" && retryMs) { + markConnectionQuotaExhausted(accountId, retryMs); + } + const creditsAlreadyInjected = (transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null; @@ -1198,6 +1223,7 @@ export class AntigravityExecutor extends BaseExecutor { if ( category === "quota_exhausted" && + decision.kind !== "full_quota_exhausted" && !creditsAlreadyInjected && shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off") ) { @@ -1269,13 +1295,6 @@ export class AntigravityExecutor extends BaseExecutor { } } - // 4. Decide final retry time (apply 4-tier engine) - const decision: Decision = decide429(category, parsedRetryMs); - retryMs = decision.retryAfterMs; - log?.debug?.( - "AG_429", - `Category: ${category}, Decision: ${decision.kind} โ€” ${decision.reason}` - ); } catch (e) { // Ignore parse errors, will fall back to exponential backoff } diff --git a/open-sse/services/antigravity429Engine.ts b/open-sse/services/antigravity429Engine.ts index f1b58d7bc0e..36364a9e6cc 100644 --- a/open-sse/services/antigravity429Engine.ts +++ b/open-sse/services/antigravity429Engine.ts @@ -32,7 +32,14 @@ export interface Decision { reason: string; } -const QUOTA_EXHAUSTED_KEYWORDS = ["quota_exhausted", "quota exhausted"]; +const QUOTA_EXHAUSTED_KEYWORDS = [ + "quota_exhausted", + "quota exhausted", + // Antigravity native message: "Individual quota reached. Contact your administrator to enable overages." + "quota reached", + "enable overages", + "individual quota", +]; const CREDITS_EXHAUSTED_KEYWORDS = [ "google_one_ai", diff --git a/tests/unit/antigravity-429-quota-cooldown.test.ts b/tests/unit/antigravity-429-quota-cooldown.test.ts new file mode 100644 index 00000000000..3791e4ff2a1 --- /dev/null +++ b/tests/unit/antigravity-429-quota-cooldown.test.ts @@ -0,0 +1,98 @@ +/** + * TDD regression tests for #3707: + * 1. `decide429("quota_exhausted")` โ†’ `full_quota_exhausted` verdict (engine contract) + * 2. `markConnectionQuotaExhausted` persists the 24h cooldown in the DB so that + * cross-request and post-restart routing skips exhausted connections. + * + * Bug: before the fix the executor never called `setConnectionRateLimitUntil`, + * so `isConnectionRateLimited` always returned false for AG connections that + * had their daily quota exhausted โ€” learned state was lost on restart. + */ + +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-ag-quota-")); +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"); + +import { + classify429, + decide429, + FULL_QUOTA_COOLDOWN_MS, +} from "../../open-sse/services/antigravity429Engine.ts"; +import { markConnectionQuotaExhausted } from "../../open-sse/executors/antigravity.ts"; + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// โ”€โ”€ Engine contract (regression guard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test("decide429: quota_exhausted category โ†’ full_quota_exhausted kind with 24h cooldown", () => { + const decision = decide429("quota_exhausted", null); + assert.equal(decision.kind, "full_quota_exhausted"); + assert.equal(decision.retryAfterMs, FULL_QUOTA_COOLDOWN_MS); + assert.equal(FULL_QUOTA_COOLDOWN_MS, 24 * 60 * 60 * 1000, "cooldown must be 24h"); +}); + +test("decide429: quota_exhausted with explicit retryAfterMs preserves the provided value", () => { + const twoDaysMs = 2 * 24 * 60 * 60 * 1000; + const decision = decide429("quota_exhausted", twoDaysMs); + assert.equal(decision.kind, "full_quota_exhausted"); + assert.equal(decision.retryAfterMs, twoDaysMs); +}); + +test("classify429: AG 'Individual quota reached' message โ†’ quota_exhausted", () => { + const msg = + "Individual quota reached. Contact your administrator to enable overages. Resets in 14h22m."; + assert.equal(classify429(msg), "quota_exhausted"); +}); + +// โ”€โ”€ DB persistence (the missing wire โ€” Bug #2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test("markConnectionQuotaExhausted persists 24h cooldown; isConnectionRateLimited returns true", async () => { + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "AG Test Quota", + }); + const connId = (conn as any).id; + + assert.equal( + providersDb.isConnectionRateLimited(connId), + false, + "should start as not rate-limited" + ); + + markConnectionQuotaExhausted(connId, FULL_QUOTA_COOLDOWN_MS); + + assert.equal( + providersDb.isConnectionRateLimited(connId), + true, + "should be rate-limited after marking quota exhausted" + ); +}); + +test("markConnectionQuotaExhausted: expired cooldown does not block the connection", async () => { + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "AG Test Expired", + }); + const connId = (conn as any).id; + + // Set cooldown in the past โ€” simulates expired cooldown + providersDb.setConnectionRateLimitUntil(connId, Date.now() - 1); + assert.equal( + providersDb.isConnectionRateLimited(connId), + false, + "expired cooldown should not block" + ); +}); From c5924a763900070b5bd7a69bb26de8abaf40cf79 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:01:44 -0400 Subject: [PATCH 08/36] Preserve xhigh reasoning effort by default (#3756) Integrated into release/v3.8.24 --- open-sse/config/providerModels.ts | 33 ++- open-sse/executors/base.ts | 28 ++- package-lock.json | 216 +++++++++--------- .../base-executor-sanitize-effort.test.ts | 83 ++++--- tests/unit/executor-base-utils.test.ts | 4 +- tests/unit/provider-models-config.test.ts | 37 ++- 6 files changed, 246 insertions(+), 155 deletions(-) diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 99a42ea019f..6351ad72634 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -61,6 +61,7 @@ export function getModelsByProviderId(providerId: string): RegistryModel[] { const CLAUDE_MODEL_PATTERN = /(?:^|[\/._-])claude(?:[._-]|$)/; const CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS = [/(?:^|[\/._-])haiku(?:[._-]|$)/] as const; +const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; export function supportsClaudeMaxEffort(modelId: string | null | undefined): boolean { if (typeof modelId !== "string" || modelId.length === 0) return false; @@ -73,19 +74,37 @@ export function supportsClaudeMaxEffort(modelId: string | null | undefined): boo ); } +function resolveProviderModelList(aliasOrId: string): { + alias: string; + models: RegistryModel[] | null; +} { + const resolvedId = aliasOrId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX) ? "claude" : aliasOrId; + const alias = PROVIDER_ID_TO_ALIAS[resolvedId] || resolvedId; + const models = PROVIDER_MODELS[alias] || PROVIDER_MODELS[resolvedId] || null; + return { alias, models }; +} + export function supportsXHighEffort(aliasOrId: string, modelId: string): boolean { - const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; - const providerModels = PROVIDER_MODELS[alias] || PROVIDER_MODELS[aliasOrId]; + const { models: providerModels } = resolveProviderModelList(aliasOrId); // Unknown provider (not in registry) โ€” pass through unchanged. if (!providerModels) return true; - const model = getProviderModel(alias, modelId); + const model = providerModels.find((entry) => entry.id === modelId); + + // Keep explicit false entries as the unsupported-model list. Unlisted models + // and models without an explicit flag pass through unchanged. + return model?.supportsXHighEffort !== false; +} + +export function supportsXHighEffortForMaxNormalization( + aliasOrId: string, + modelId: string +): boolean { + const { alias, models: providerModels } = resolveProviderModelList(aliasOrId); + if (!providerModels) return true; + const model = providerModels.find((entry) => entry.id === modelId); - // Claude Code models default to supporting extra-high effort. Keep explicit - // false entries as the unsupported-model list so newly added Claude models do - // not need opt-in flags. if (alias === "cc") { return model?.supportsXHighEffort !== false; } - return model?.supportsXHighEffort === true; } diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index f940e630d6c..2da49931ff7 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,6 +1,10 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; -import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; +import { + supportsClaudeMaxEffort, + supportsXHighEffort, + supportsXHighEffortForMaxNormalization, +} from "../config/providerModels.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; import type { Session } from "../services/sessionPool/session.ts"; import { SessionPool } from "../services/sessionPool/sessionPool.ts"; @@ -25,7 +29,10 @@ import { modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { cloakThirdPartyToolNames, remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts"; +import { + cloakThirdPartyToolNames, + remapToolNamesInRequest, +} from "../services/claudeCodeToolRemapper.ts"; import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts"; import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; @@ -223,12 +230,11 @@ function hasActiveClaudeThinking(body: Record): boolean { * Each rejection burns a combo fallback attempt before reaching a working * provider. Apply provider-aware sanitation here (after transformRequest, so * reintroductions by per-provider transforms are also caught) before fetch. - * xhigh support is registry-gated: models that genuinely support xhigh pass - * through unchanged, and Claude models default to xhigh support unless marked - * as legacy unsupported entries. max support is Claude/CC-compatible only and + * xhigh support is opt-out: pass through unchanged unless the registry marks + * a model as unsupported. max support is Claude/CC-compatible only and * intentionally separate: older Opus/Sonnet models may support max even when - * they do not support xhigh. For OpenAI-shape providers, normalize max to - * xhigh when that top tier is allowed; otherwise downgrade to high. + * they do not support xhigh. For OpenAI-shape providers, keep the existing + * max normalization behavior. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; @@ -260,10 +266,10 @@ export function sanitizeReasoningEffortForProvider( const supportsXHigh = supportsXHighEffort(provider, modelStr); const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; - const shouldNormalizeMaxToXHigh = - effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr) && supportsXHigh; - const shouldDowngradeMax = - effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr) && !supportsXHigh; + const supportsXHighForMax = supportsXHighEffortForMaxNormalization(provider, modelStr); + const supportsMax = supportsMaxEffortForProvider(provider, modelStr); + const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax; + const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax; if (shouldNormalizeMaxToXHigh) { log?.info?.( diff --git a/package-lock.json b/package-lock.json index 60a548c2c59..057d2a036fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^3.0.0", "@types/better-sqlite3": "^7.6.13", - "@types/bun": "latest", + "@types/bun": "*", "@types/keytar": "^4.4.2", "@types/node": "^25.9.1", "@types/react": "^19.2.15", @@ -1351,9 +1351,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1367,9 +1367,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1383,9 +1383,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1399,9 +1399,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1415,9 +1415,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1431,9 +1431,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1447,9 +1447,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1463,9 +1463,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1479,9 +1479,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1495,9 +1495,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1511,9 +1511,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1527,9 +1527,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1543,9 +1543,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1559,9 +1559,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1575,9 +1575,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1591,9 +1591,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1607,9 +1607,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1623,9 +1623,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1639,9 +1639,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1655,9 +1655,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1671,9 +1671,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1687,9 +1687,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1703,9 +1703,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1719,9 +1719,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1735,9 +1735,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1751,9 +1751,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -10317,9 +10317,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -10329,32 +10329,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index 9f2eea1266e..fc909674785 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -11,7 +11,7 @@ function makeLog() { }; } -test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh โ†’ high", () => { +test("sanitizeReasoningEffortForProvider: xiaomi-mimo preserves xhigh by default", () => { const log = makeLog(); const body = { model: "mimo-v2.5-pro", @@ -19,15 +19,60 @@ test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh โ†’ high" messages: [{ role: "user", content: "hi" }], }; const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", log); + assert.equal(result, body, "xhigh passes through unless the model explicitly opts out"); + assert.equal((result as any).reasoning_effort, "xhigh"); + assert.equal((result as any).model, "mimo-v2.5-pro", "other fields preserved"); + assert.equal(log.messages.length, 0); +}); + +test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek preserves xhigh", () => { + const body = { + model: "deepseek/deepseek-v4-pro", + reasoning_effort: "xhigh", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "openrouter", + "deepseek/deepseek-v4-pro", + null + ); + assert.equal(result, body); + assert.equal((result as any).reasoning_effort, "xhigh"); +}); + +test("sanitizeReasoningEffortForProvider: explicit xhigh opt-out downgrades to high", () => { + const log = makeLog(); + const body = { + model: "claude-opus-4-6", + reasoning_effort: "xhigh", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "claude", "claude-opus-4-6", log); assert.notEqual(result, body, "must return a new object when mutating"); assert.equal((result as any).reasoning_effort, "high"); - assert.equal((result as any).model, "mimo-v2.5-pro", "other fields preserved"); assert.ok( log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /xhigh โ†’ high/.test(m)), "logs the downgrade" ); }); +test("sanitizeReasoningEffortForProvider: Anthropic-compatible dynamic provider honors xhigh opt-out", () => { + const body = { + model: "claude-opus-4-6", + reasoning_effort: "xhigh", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "anthropic-compatible-test", + "claude-opus-4-6", + null + ); + assert.notEqual(result, body, "must return a new object when mutating"); + assert.equal((result as any).reasoning_effort, "high"); +}); + test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades max โ†’ high", () => { const log = makeLog(); const body = { @@ -125,29 +170,25 @@ test("sanitizeReasoningEffortForProvider: claude preserves max for Opus/Sonnet a assert.equal((haikuResult as any).reasoning_effort, "high"); }); -test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh in nested reasoning.effort", () => { +test("sanitizeReasoningEffortForProvider: xiaomi-mimo preserves nested xhigh by default", () => { const body = { model: "mimo-v2.5-pro", reasoning: { effort: "xhigh", summary: "auto" }, messages: [], }; const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", null); - assert.equal((result as any).reasoning.effort, "high"); + assert.equal(result, body); + assert.equal((result as any).reasoning.effort, "xhigh"); assert.equal((result as any).reasoning.summary, "auto", "other reasoning fields preserved"); }); -test("sanitizeReasoningEffortForProvider: nested reasoning downgrade preserves Responses shape", () => { +test("sanitizeReasoningEffortForProvider: explicit xhigh opt-out preserves Responses shape", () => { const body = { - model: "responses-only-model", + model: "claude-opus-4-6", reasoning: { effort: "xhigh", summary: "auto" }, input: [], }; - const result = sanitizeReasoningEffortForProvider( - body, - "xiaomi-mimo", - "responses-only-model", - null - ); + const result = sanitizeReasoningEffortForProvider(body, "claude", "claude-opus-4-6", null); assert.equal((result as any).reasoning.effort, "high"); assert.equal((result as any).reasoning_effort, undefined); }); @@ -197,22 +238,14 @@ test("sanitizeReasoningEffortForProvider: mistral/devstral preserves reasoning w assert.deepEqual((result as any).reasoning, { summary: "auto" }); }); -test("sanitizeReasoningEffortForProvider: codex with xhigh passes through unchanged when model supports it", () => { - // codex/gpt-5.5-xhigh is flagged supportsXHighEffort:true in providerRegistry. - // Claude Opus 4.7+ models default to xhigh support unless explicitly opted out. +test("sanitizeReasoningEffortForProvider: codex with xhigh passes through unchanged", () => { const body = { model: "gpt-5.5-xhigh", reasoning_effort: "xhigh", messages: [], }; const result = sanitizeReasoningEffortForProvider(body, "codex", "gpt-5.5-xhigh", null); - // Either passes through unchanged (supportsXHighEffort=true) - // or the registry doesn't flag it โ€” in which case downgrade is acceptable. - // We assert no error and that some reasoning_effort is present. - assert.ok( - (result as any).reasoning_effort === "xhigh" || (result as any).reasoning_effort === "high", - "either preserved (xhigh) or downgraded (high)" - ); + assert.equal((result as any).reasoning_effort, "xhigh"); }); test("sanitizeReasoningEffortForProvider: no-op when reasoning_effort absent", () => { @@ -224,10 +257,8 @@ test("sanitizeReasoningEffortForProvider: no-op when reasoning_effort absent", ( test("sanitizeReasoningEffortForProvider: handles unknown providers as pass-through", () => { const body = { model: "some-model", reasoning_effort: "xhigh", messages: [] }; const result = sanitizeReasoningEffortForProvider(body, "unknown-provider", "some-model", null); - // unknown provider + xhigh + model not in registry โ†’ supportsXHighEffort returns false โ†’ downgrade - // OR unknown provider isn't in the strip list โ†’ returns xhigh - // Both are acceptable behavior; we just assert no exception thrown. - assert.ok(result !== undefined); + assert.equal(result, body); + assert.equal((result as any).reasoning_effort, "xhigh"); }); test("sanitizeReasoningEffortForProvider: non-object body returns unchanged", () => { diff --git a/tests/unit/executor-base-utils.test.ts b/tests/unit/executor-base-utils.test.ts index d3727c205e2..fd869228982 100644 --- a/tests/unit/executor-base-utils.test.ts +++ b/tests/unit/executor-base-utils.test.ts @@ -122,10 +122,10 @@ test("sanitizeReasoningEffortForProvider passes through body without reasoning_e assert.deepEqual(result, body); }); -test("sanitizeReasoningEffortForProvider clamps xhigh to high for unsupported providers", () => { +test("sanitizeReasoningEffortForProvider preserves xhigh unless explicitly unsupported", () => { const body = { reasoning_effort: "xhigh" }; const result = base.sanitizeReasoningEffortForProvider(body, "openai", "gpt-4o") as any; - assert.equal(result.reasoning_effort, "high"); + assert.equal(result.reasoning_effort, "xhigh"); }); test("sanitizeReasoningEffortForProvider preserves high effort", () => { diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 0ebd754469d..bdfcc537797 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -12,6 +12,7 @@ import { isValidModel, supportsClaudeMaxEffort, supportsXHighEffort, + supportsXHighEffortForMaxNormalization, } from "../../open-sse/config/providerModels.ts"; test("provider models helpers expose model lists and defaults", () => { @@ -106,7 +107,7 @@ test("Claude max effort support excludes Haiku family and non-Claude IDs", () => assert.equal(supportsClaudeMaxEffort("claude-future-5-0"), true); }); -test("Claude xhigh effort support defaults on for new models and opts out legacy models", () => { +test("xhigh effort support defaults to pass-through and opts out explicit false models", () => { const claudeModels = new Set(getModelsByProviderId("claude").map((model) => model.id)); assert.ok(claudeModels.has("claude-opus-4-8")); @@ -115,4 +116,38 @@ test("Claude xhigh effort support defaults on for new models and opts out legacy assert.equal(supportsXHighEffort("claude", "claude-opus-4-6"), false); assert.equal(supportsXHighEffort("claude", "claude-sonnet-4-6"), false); assert.equal(supportsXHighEffort("claude", "claude-future-5-0"), true); + assert.equal(supportsXHighEffort("anthropic-compatible-test", "claude-opus-4-6"), false); + assert.equal(supportsXHighEffort("anthropic-compatible-test", "claude-opus-4-7"), true); + assert.equal(supportsXHighEffort("anthropic-compatible-cc-test", "claude-opus-4-6"), false); + assert.equal(supportsXHighEffort("anthropic-compatible-cc-test", "claude-opus-4-7"), true); + assert.equal(supportsXHighEffort("openrouter", "deepseek/deepseek-v4-pro"), true); + assert.equal(supportsXHighEffort("deepseek", "deepseek-v4-pro"), true); +}); + +test("max normalization keeps existing xhigh opt-in behavior", () => { + assert.equal( + supportsXHighEffortForMaxNormalization("openai-compatible-free1", "gemini-3.1-pro-preview"), + true + ); + assert.equal(supportsXHighEffortForMaxNormalization("xiaomi-mimo", "mimo-v2.5-pro"), false); + assert.equal( + supportsXHighEffortForMaxNormalization("anthropic-compatible-cc-test", "claude-opus-4-6"), + false + ); + assert.equal( + supportsXHighEffortForMaxNormalization("anthropic-compatible-cc-test", "claude-opus-4-7"), + true + ); + assert.equal( + supportsXHighEffortForMaxNormalization("anthropic-compatible-test", "claude-opus-4-6"), + false + ); + assert.equal( + supportsXHighEffortForMaxNormalization("anthropic-compatible-test", "claude-opus-4-7"), + true + ); + assert.equal( + supportsXHighEffortForMaxNormalization("openrouter", "deepseek/deepseek-v4-pro"), + false + ); }); From da583308450617c3ab4dd3a1bed7342bf0b6bde0 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:01:47 -0400 Subject: [PATCH 09/36] fix logs light mode controls (#3760) Integrated into release/v3.8.24 --- src/app/(dashboard)/dashboard/logs/page.tsx | 4 +- .../settings/components/FeatureFlagCard.tsx | 101 +++++-- .../settings/components/FeatureFlagsGrid.tsx | 253 ++++++++++-------- src/shared/components/RequestLoggerV2.tsx | 178 +++++++----- .../components/requestLoggerPreferences.ts | 44 +++ tests/unit/request-logger-endpoints.test.ts | 14 +- tests/unit/request-logger-preferences.test.ts | 66 +++++ 7 files changed, 451 insertions(+), 209 deletions(-) create mode 100644 src/shared/components/requestLoggerPreferences.ts create mode 100644 tests/unit/request-logger-preferences.test.ts diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 1fd393f83c3..c99aafd9554 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -131,8 +131,8 @@ export default function LogsPage() { onClick={() => setShowCleanHistory(true)} disabled={cleaningHistory} className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg - border border-red-500/30 bg-red-500/10 text-red-200 hover:bg-red-500/20 - hover:border-red-400/50 transition-all duration-200 + border border-red-500/30 bg-red-500/10 text-red-700 hover:bg-red-500/15 + hover:border-red-500/50 dark:text-red-300 dark:hover:bg-red-500/20 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > = { - security: { bg: "bg-red-500/15", text: "text-red-400", label: "Security" }, - network: { bg: "bg-blue-500/15", text: "text-blue-400", label: "Network" }, - policies: { bg: "bg-amber-500/15", text: "text-amber-400", label: "Policies" }, - runtime: { bg: "bg-purple-500/15", text: "text-purple-400", label: "Runtime" }, - cli: { bg: "bg-green-500/15", text: "text-green-400", label: "CLI" }, - health: { bg: "bg-cyan-500/15", text: "text-cyan-400", label: "Health" }, + security: { + bg: "bg-red-50 dark:bg-red-500/15", + border: "border-red-200 dark:border-red-500/20", + text: "text-red-700 dark:text-red-300", + label: "Security", + }, + network: { + bg: "bg-sky-50 dark:bg-blue-500/15", + border: "border-sky-200 dark:border-blue-500/20", + text: "text-sky-700 dark:text-blue-300", + label: "Network", + }, + policies: { + bg: "bg-amber-50 dark:bg-amber-500/15", + border: "border-amber-200 dark:border-amber-500/20", + text: "text-amber-700 dark:text-amber-300", + label: "Policies", + }, + runtime: { + bg: "bg-violet-50 dark:bg-purple-500/15", + border: "border-violet-200 dark:border-purple-500/20", + text: "text-violet-700 dark:text-purple-300", + label: "Runtime", + }, + cli: { + bg: "bg-emerald-50 dark:bg-green-500/15", + border: "border-emerald-200 dark:border-green-500/20", + text: "text-emerald-700 dark:text-green-300", + label: "CLI", + }, + health: { + bg: "bg-cyan-50 dark:bg-cyan-500/15", + border: "border-cyan-200 dark:border-cyan-500/20", + text: "text-cyan-700 dark:text-cyan-300", + label: "Health", + }, }; const SOURCE_STYLES: Record< FeatureFlagCardProps["flag"]["source"], - { bg: string; text: string; label: string } + { bg: string; border: string; text: string; label: string } > = { - db: { bg: "bg-blue-500/20", text: "text-blue-300", label: "DB" }, - env: { bg: "bg-amber-500/20", text: "text-amber-300", label: "ENV" }, - default: { bg: "bg-slate-500/20", text: "text-slate-400", label: "DEF" }, + db: { + bg: "bg-sky-50 dark:bg-blue-500/20", + border: "border-sky-200 dark:border-blue-500/30", + text: "text-sky-700 dark:text-blue-300", + label: "DB", + }, + env: { + bg: "bg-amber-50 dark:bg-amber-500/20", + border: "border-amber-200 dark:border-amber-500/30", + text: "text-amber-700 dark:text-amber-300", + label: "ENV", + }, + default: { + bg: "bg-slate-100 dark:bg-slate-500/20", + border: "border-slate-200 dark:border-slate-500/30", + text: "text-slate-600 dark:text-slate-300", + label: "DEF", + }, }; function isEnabled(value: string): boolean { @@ -46,7 +91,7 @@ function isEnabled(value: string): boolean { function Spinner() { return (