diff --git a/.env.example b/.env.example index 975a66d6d1..0be8cec9d4 100644 --- a/.env.example +++ b/.env.example @@ -476,6 +476,11 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 # to opt out (restores fully concurrent fetches). Default: 1500 PROVIDER_LIMITS_SYNC_SPACING_MS=1500 +# Delay (ms) before refreshing provider limits after a real usage event (e.g. a +# completed request). Gives the upstream quota API time to register the consumption +# before the dashboard polls. Default: 5000 +#PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS=5000 + # Disable all background services (sync, pricing, model refresh). # Used by: src/instrumentation-node.ts, src/lib/initCloudSync.ts # Useful for: CI builds, test environments, or resource-constrained containers. diff --git a/.gitignore b/.gitignore index 76c621540d..cbe3af7f64 100644 --- a/.gitignore +++ b/.gitignore @@ -211,3 +211,4 @@ docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute.md docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md +omniroute.md diff --git a/.npmignore b/.npmignore index 3cd4ba971a..8e4fd8d8e0 100644 --- a/.npmignore +++ b/.npmignore @@ -9,6 +9,16 @@ app/vscode-extension/ **/db.json # Source code (pre-built app/ is published instead) +# +# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what +# ships. It now allowlists the backend source closure the MCP server needs at runtime +# (open-sse/, src/lib, src/server, ...) and OVERRIDES the broad src/ + open-sse/ excludes +# below — npm honors files[] over .npmignore for inclusion. These lines are kept only as +# intent/back-stop: if files[] is ever trimmed back to specific paths, they must NOT be +# allowed to re-hide the MCP closure (that would silently reintroduce the --mcp +# ERR_MODULE_NOT_FOUND #3578 fixed). The closure gate in +# tests/unit/mcp-published-files-closure-3578.test.ts asserts the real `npm pack` output +# in both directions (closure present + zero test files), catching such a regression. src/ open-sse/ docs/ @@ -18,6 +28,17 @@ images/ logs/ scripts/ +# Co-located tests must never ship even when their parent dir is allowlisted by files[]. +# (Primary guard is the "!**/*.test.*" negations in package.json files[]; this is defense +# in depth for any nested dir the allowlist pulls in.) +**/__tests__/ +**/*.test.ts +**/*.test.tsx +**/*.test.js +**/*.test.mjs +**/*.spec.ts +**/*.spec.tsx + # Config/dev files *.md !README.md diff --git a/@omniroute/opencode-provider/README.md b/@omniroute/opencode-provider/README.md index 624fddbb6e..c69669619a 100644 --- a/@omniroute/opencode-provider/README.md +++ b/@omniroute/opencode-provider/README.md @@ -1,5 +1,23 @@ # @omniroute/opencode-provider +> ## ⚠️ Deprecated — use [`@omniroute/opencode-plugin`](https://www.npmjs.com/package/@omniroute/opencode-plugin) instead +> +> This package writes a **static** `provider.omniroute` block to `opencode.json` from a hardcoded default model list, so it **drifts behind your live OmniRoute catalog** — adding a model in OmniRoute won't show up in OpenCode until you re-run the generator, and OpenCode Desktop/Web only surfaces a subset of the static models. +> +> **`@omniroute/opencode-plugin`** solves this by fetching `GET /v1/models` from your OmniRoute instance at OpenCode startup, so the model list is always live (see [#3419](https://github.com/diegosouzapw/OmniRoute/issues/3419)). It is now the recommended path. +> +> **One-line migration** — replace the static `provider.omniroute` block in `opencode.json` with a single plugin entry: +> +> ```jsonc +> // opencode.json +> { +> "$schema": "https://opencode.ai/config.json", +> "plugin": ["@omniroute/opencode-plugin"] +> } +> ``` +> +> This package is **not removed** and still works for static/offline config generation, but it is no longer actively recommended and won't track new models automatically. + Helper for connecting [OpenCode](https://opencode.ai) to a running [OmniRoute](https://github.com/diegosouzapw/OmniRoute) AI gateway. The package emits a **schema-valid entry** for `opencode.json` (`https://opencode.ai/config.json`) that delegates the actual runtime to [`@ai-sdk/openai-compatible`](https://www.npmjs.com/package/@ai-sdk/openai-compatible). It does not ship any new HTTP client — OmniRoute already exposes an OpenAI-compatible surface, and OpenCode already speaks it through the AI SDK. diff --git a/@omniroute/opencode-provider/package.json b/@omniroute/opencode-provider/package.json index 62aafc4b5a..572643a6d5 100644 --- a/@omniroute/opencode-provider/package.json +++ b/@omniroute/opencode-provider/package.json @@ -1,7 +1,7 @@ { "name": "@omniroute/opencode-provider", "version": "0.1.0", - "description": "OpenCode provider helper for the OmniRoute AI Gateway. Generates a schema-valid provider entry for opencode.json that delegates the runtime to @ai-sdk/openai-compatible.", + "description": "DEPRECATED — use @omniroute/opencode-plugin instead (it fetches the live OmniRoute /v1/models catalog at startup, so models never drift). This static-config generator still works but is no longer the recommended path. OpenCode provider helper for the OmniRoute AI Gateway.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/@omniroute/opencode-provider/tests/index.test.ts b/@omniroute/opencode-provider/tests/index.test.ts index aac6e7a27b..ae3c11c45b 100644 --- a/@omniroute/opencode-provider/tests/index.test.ts +++ b/@omniroute/opencode-provider/tests/index.test.ts @@ -2,6 +2,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import type { Server } from "node:http"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import { buildOmniRouteOpenCodeConfig, @@ -667,3 +670,17 @@ test("createOmniRouteModesBlock honours numeric overrides limited to OC schema", assert.equal(block.build.temperature, 0.7); assert.equal(block.build.top_p, 0.9); }); + +// #3419 — soft-deprecation in favour of @omniroute/opencode-plugin. Guard the +// deprecation notice so it can't be silently dropped while the package is kept +// publishing (it still works; it is just no longer the recommended path). +test("package is marked deprecated in favour of @omniroute/opencode-plugin (#3419)", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")); + assert.match(pkg.description, /DEPRECATED/); + assert.match(pkg.description, /@omniroute\/opencode-plugin/); + + const readme = readFileSync(join(here, "..", "README.md"), "utf8"); + assert.match(readme, /Deprecated/i); + assert.match(readme, /@omniroute\/opencode-plugin/); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fffde046b..d3e24dea84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,43 @@ ## [Unreleased] -### ✨ New Features +--- -- **feat(providers):** add Claude Fable 5 (`claude-fable-5`) — wires the new flagship model across the full pipeline: `cc` and `kiro` provider registries (1M context, 128k output), pricing constants, model spec (adaptive thinking, vision, tool use), fast mode, 1M-context beta header, fallback chain (`claude-fable-5 → claude-opus-4-8 → claude-opus-4-7 → claude-sonnet-4-6`), and cost data. +## [3.8.21] — 2026-06-11 + +### ✨ Added + +- **feat(cli):** `omniroute autostart` now accepts the shorthand the headless / `omniroute serve` path was missing — `omniroute autostart on` / `... true` (aliases of `enable`), `... off` / `... false` (aliases of `disable`), a new `... toggle`, and a default `... status` (bare `omniroute autostart` is a safe read-only). Previously autostart could only be toggled from the tray (`serve --tray`) or the Electron Appearance tab, so a plain `omniroute serve` user had no way to enable it. (The cross-platform launchd/systemd/registry logic is unchanged — this only wires the ergonomic CLI surface.) ([#3331](https://github.com/diegosouzapw/OmniRoute/issues/3331) — thanks @uniQta) + +### ♻️ Code Quality + +- **refactor(chatCore):** extract the chatCore request phases — idempotency check, semantic cache check, common request sanitization, and memory/skills injection — into dedicated `open-sse/handlers/chatCore/` modules (`idempotency.ts`, `semanticCache.ts`, `sanitization.ts`, `memorySkillsInjection.ts`), slimming the monolithic handler with no behavior change. (Maintainer follow-up: re-derive `idempotencyKey` at the Phase 9.2 save site after the check moved into the module, fixing a `ReferenceError` on successful non-cached responses.) ([#3598](https://github.com/diegosouzapw/OmniRoute/pull/3598) — thanks @oyi77) +- **docs(opencode-provider):** soft-deprecate `@omniroute/opencode-provider` in favour of `@omniroute/opencode-plugin`. The provider package writes a **static** model list to `opencode.json` that drifts behind the live OmniRoute catalog, whereas the plugin fetches `/v1/models` at OpenCode startup. The package keeps working (no code/behavior change), but its npm description and README now carry a deprecation banner with the one-line migration, and a guard test pins the notice. ([#3419](https://github.com/diegosouzapw/OmniRoute/issues/3419) — thanks @herjarsa) +- **chore(review):** pre-release hardening from a multi-reviewer `/review-reviews` battery over the v3.8.21 diff (7 Opus reviewers; zero blocker/high). Resolved findings: npm tarball no longer ships co-located test files (`files[]` negations + reconciled `.npmignore`; the #3578 closure gate now asserts the real `npm pack` output in both directions); `getSanitizedCachedProviderLimitsMap` scopes its connection scan to antigravity/agy instead of decrypting every active connection on each dashboard poll; the Antigravity quota-tier remap (`toClientAntigravityQuotaModelId`) is centralized in `antigravityModelAliases.ts` (was an inline if-ladder in `usage.ts`); the chatCore idempotency check returns its resolved key so the save site reuses a single derivation; and new tests pin the chatCore extracted modules, the Antigravity `usage_history` fallback contract, the reasoning-wrapper prefix-preservation heuristic, the Antigravity SSE `markdown` branch, and the upstream-ca/test no-persist guarantee. (Live-verified that agy consumer tokens are accepted by the non-daily `cloudcode-pa` host used by `retrieveUserQuota`, so #3604 is not agy-host-limited.) ### 🔧 Bug Fixes -- **fix(translator):** scope the Gemini `thoughtSignature` bypass to the Antigravity/CLI path and unwrap array-shaped Gemini error bodies — signature-less historical tool calls on Antigravity/CLI are emitted as native parts carrying the `skip_thought_signature_validator` sentinel (preventing upstream 400s). (The standard Gemini direct path was kept on text/context representation here, then switched to native by #3569 below.) ([#3560](https://github.com/diegosouzapw/OmniRoute/pull/3560) — thanks @oyi77 and @Six7Day via [#3414](https://github.com/diegosouzapw/OmniRoute/pull/3414)) -- **fix(translator):** the standard Gemini direct path now maps historical tool calls to **native** `functionCall`/`functionResponse` parts instead of inert text. The previous text serialization (`[tool_history_call: …]` / "Historical tool-call record only …") leaked into the model's visible output (the model echoed the annotation text). A live test against the real Gemini API confirmed thinking models accept signature-less native `functionCall` parts (`gemini-2.5-flash` returns 200 even with `tools` + `thinkingConfig`), so the text mode is no longer needed as the default and the leak is gone. The Antigravity/CLI sentinel path (#3560) is untouched. ([#3569](https://github.com/diegosouzapw/OmniRoute/pull/3569) — thanks @hartmark) -- **fix(auto-update):** the self-update flow now resolves a stable project root instead of the launch directory — `resolveProjectRoot` walks up from the module's own location (`__dirname`) to the nearest `package.json`/`.git` rather than trusting `process.cwd()` (which could point anywhere the process was started from), and every `git`/`npm`/`pm2` step in `version/route.ts` runs against that `PROJECT_ROOT`. The original resolver fell through to `return cwd` on every branch, making `PROJECT_ROOT` a no-op; the walker (with TDD coverage) fixes that. ([#3561](https://github.com/diegosouzapw/OmniRoute/pull/3561) — thanks @oyi77; `PROJECT_ROOT` originally introduced + `version/route.ts` call sites wired in [#3423](https://github.com/diegosouzapw/OmniRoute/pull/3423) — thanks @ViFigueiredo) -- **fix(plugins):** wire plugin lifecycle hooks (`onInstall`/`onActivate`/`onDeactivate`/`onUninstall`) in the loader so `manager.ts` can register them with `emitHook` — they were declared in the manifest schema and dispatched by the manager, but the loader never built the `plugin.onX` methods, leaving the lifecycle hooks declared-but-dead. Also addresses #3518 review comments (redundant `RegExp(/.../)` → literals, `logs/[id]` route awaits the Next route `params`, indentation). ([#3562](https://github.com/diegosouzapw/OmniRoute/pull/3562) — thanks @oyi77) +- **fix(routing):** reasoning models (deepseek-v4-flash, nemotron, etc.) no longer return empty content in combo routing when they spend all of `max_tokens` on reasoning — `validateResponseQuality` now rejects an empty-content-but-`reasoning_content` response when reasoning consumed ≥90% of completion tokens (so the combo loop retries/falls back), and reasoning models receive a `max_tokens` buffer (+50%, +1000 floor) so reasoning and content both fit. (Maintainer follow-up: the round-robin buffer is applied to a per-attempt copy so it does not compound across models/retries — `4096 → 6144 → 9216 → …`.) ([#3588](https://github.com/diegosouzapw/OmniRoute/pull/3588) — thanks @herjarsa) +- **fix(routing):** a valid `max_tokens`-truncated upstream response is no longer misclassified as empty content and rewritten into a fake 502 — `isEmptyContentResponse()` flagged any Claude `content:[]` / OpenAI empty-choice payload regardless of `stop_reason`/`finish_reason`, so a Claude Code `max_tokens: 1` connectivity ping (HTTP 200, `stop_reason:"max_tokens"`, empty content) became a synthetic `502 "Provider returned empty content"` and triggered a needless family fallback. The guard now treats a terminal truncation/tool signal (Claude `stop_reason` `max_tokens`/`tool_use`, OpenAI `finish_reason` `length`/`tool_calls`) as a legitimate completion; genuinely empty responses (no terminal reason, or `stop`/`end_turn` with empty content) are still caught. ([#3572](https://github.com/diegosouzapw/OmniRoute/issues/3572)) +- **fix(api):** `/v1/completions` now returns the legacy OpenAI Completions shape (`object:"text_completion"`, `choices[].text`) instead of chat payloads (`choices[].message|delta.content`) — the endpoint routes internally through the chat pipeline, so legacy Completion clients like TabbyML's `openai/completion` backend crashed with `missing field "text"`. The response (both non-streaming JSON and the SSE stream) is now translated back to the text-completion shape; `[DONE]` and error bodies pass through unchanged. ([#3571](https://github.com/diegosouzapw/OmniRoute/issues/3571)) +- **fix(usage):** the z.ai/GLM coding-plan quota card no longer shows "Monthly 0%" — coding plans have no monthly cap (only 5-hour windows), so the quota API reports the `TIME_LIMIT` ("Monthly") entry with `total=0`, and the `total>0 ? … : 0` fallback rendered a misleading 0% remaining (which can skew downstream model-choice). With no absolute cap the remaining percentage now falls back to the percentage-derived value (full/100% when 0% used). ([#3580](https://github.com/diegosouzapw/OmniRoute/issues/3580)) +- **docs(discovery):** mark `DISCOVERY_TOOL_DESIGN.md`'s API Endpoints table with an explicit "⚠️ Not yet implemented — Phase 2" banner — the discovery routes are a design proposal (Phase-1 stub only), and the banner makes clear the `KNOWN_STALE_DOC_REFS` gate suppression is intentional, not stale drift. ([#3498](https://github.com/diegosouzapw/OmniRoute/issues/3498)) +- **fix(agent-bridge):** add the missing `POST /api/tools/agent-bridge/upstream-ca/test` route — the UpstreamCaField "Test" button POSTed to it but it didn't exist (404). The new validate-only route checks the CA file exists and is a parseable PEM certificate (returns the subject/expiry) **without** persisting the path or activating it; it inherits the `/api/tools/agent-bridge/` LOCAL_ONLY classification. ([#3488](https://github.com/diegosouzapw/OmniRoute/issues/3488)) +- **fix(gamification):** the dashboard Profile page no longer hits three 404s — added the missing `GET /api/gamification/{level,badges,badges/earned}` routes (management-scoped). The page is operator-wide (no `apiKeyId`), so `level`/`badges/earned` aggregate across all keys (with an optional `?apiKeyId` for a single key), and `badges` seeds the built-in catalog first (idempotent) so the grid is populated even on installs that never seeded it (see #3472). ([#3484](https://github.com/diegosouzapw/OmniRoute/issues/3484)) +- **security(oauth):** migrate the five public OAuth client_ids (Claude, Codex, Qwen, Kimi, GitHub Copilot — 9 server-side call-sites in `providerRegistry.ts` + `oauth.ts`) from string literals to `resolvePublicCred()` (Hard Rule #11), matching the existing Gemini/Antigravity pattern. The values decode byte-for-byte to the same public client_ids (env overrides still win), so OAuth flows are unchanged; the `check-public-creds` allowlist is now empty. The browser-bundled `codexDeviceFlow.ts` copy stays a literal by necessity (it cannot import `open-sse`). ([#3493](https://github.com/diegosouzapw/OmniRoute/issues/3493)) +- **fix(mcp):** `omniroute --mcp` no longer crashes on npm installs with `ERR_MODULE_NOT_FOUND` (e.g. `src/lib/combos/steps.ts`) — the MCP server runs from raw TypeScript and imports across `src/` + `open-sse/`, but the published `files` allowlist only shipped a handful of cherry-picked paths, so the transitive closure (~400 files) was absent from the tarball. `files` now ships the backend source the MCP server needs (`open-sse/` + `src/{domain,lib,mitm,server,shared,sse,types}/`, excluding the `src/app` UI), and a new regression test computes the MCP import closure and fails if any reachable source file is not covered by `files`. ([#3578](https://github.com/diegosouzapw/OmniRoute/issues/3578)) +- **fix(api):** `API_REFERENCE.md` no longer documents a non-existent `/api/guardrails*` / `/api/shadow*` surface (doc-fiction flagged by `check-docs-symbols`, frozen in `KNOWN_STALE_DOC_REFS`). The guardrail pipeline is real (`src/lib/guardrails`), so the two routes that map to actual behavior are now implemented — `GET /api/guardrails` (list the registered guardrails + status) and `POST /api/guardrails/test` (dry-run the pre-call pipeline over a sample input), both management-scoped — while the fictional `enable`/`disable`/`logs` rows and the entire `/api/shadow*` table (shadow A-B comparison is combo-config + `/api/combos/metrics`) were removed from the doc and dropped from the allowlist. ([#3496](https://github.com/diegosouzapw/OmniRoute/issues/3496)) +- **fix(agent-bridge):** the MITM "Start" button no longer reports a misleading "port 443 may be in use" for every failure cause — `startMitm()` only matched the EADDRINUSE stderr line and always threw the port-443 message, so a missing `ROUTER_API_KEY` or an `EACCES` permission error sent users debugging the wrong thing. The startup watcher now buffers the MITM child's stderr and `interpretMitmStartupError()` maps the real `server.cjs` `❌` cause (port-in-use / permission-denied / missing API key / any other diagnostic line) into the surfaced error; with no captured output it stays generic instead of guessing port 443. ([#3606](https://github.com/diegosouzapw/OmniRoute/issues/3606)) +- **fix(oauth):** Kiro "Import Token" no longer reports a bare `Internal server error` that hides the real cause — the import validates/refreshes the pasted refresh token against AWS, and the catch returned a generic 500 string, so an `invalid_grant`, an expired token, or a region mismatch all surfaced identically in the dashboard. The import error now carries the sanitized upstream cause via `sanitizeErrorMessage()` (Hard Rule #12 — no stack, no secrets), keeping the same `{ error: }` response shape, and still falls back to the generic message when there is nothing to report. ([#3589](https://github.com/diegosouzapw/OmniRoute/issues/3589)) +- **fix(antigravity):** the Antigravity/agy Gemini 3.5 Flash catalog now exposes clean public tier IDs (`gemini-3.5-flash-low`/`-medium`/`-high`, matching Antigravity 2.0.4's Low/Medium/High selector) and maps them to the live upstream IDs at the executor boundary, instead of the old confusing `-preview`/`-agent` names. Antigravity model-id normalization moved out of the global model resolver into the executor so client-visible IDs are no longer rewritten before account/credential routing and logging. (Maintainer follow-up: kept `gemini-3.5-flash-preview` as a hidden backward-compat alias routing to the High tier so saved combos/configs keep working; live-validated the tier set via the `agy` CLI catalog.) ([#3603](https://github.com/diegosouzapw/OmniRoute/pull/3603) — thanks @dhaern) +- **fix(usage):** Antigravity/agy Provider Limits now report accurate consumption — `retrieveUserQuota` (live usage) is preferred over the `fetchAvailableModels` catalog view (which keeps reporting full buckets after real usage), with a local `usage_history` fallback for buckets that are only catalog-visible; cached entries are sanitized so retired upstream IDs are not re-exposed, and a deduplicated post-usage refresh keeps the dashboard fresh after each request. (Maintainer follow-up: the post-usage refresh is decoupled through a lightweight `usageEvents` bus so `usageHistory` no longer imports `providerLimits`/the executors graph, keeping the `typecheck:core` surface stable.) ([#3604](https://github.com/diegosouzapw/OmniRoute/pull/3604) — thanks @dhaern) +- **fix(gemini):** textual reasoning wrappers emitted as assistant prose (``/``/``/``, including malformed/open tags like ` { const globalOpts = c.optsWithGlobals(); @@ -19,6 +24,7 @@ export function registerAutostart(program) { cmd .command("disable") + .aliases(["off", "false"]) .description(t("autostart.disable") || "Disable autostart at login") .action(async (opts, c) => { const globalOpts = c.optsWithGlobals(); @@ -29,7 +35,19 @@ export function registerAutostart(program) { }); cmd - .command("status") + .command("toggle") + .description(t("autostart.toggle") || "Toggle autostart at login") + .action(async (opts, c) => { + const globalOpts = c.optsWithGlobals(); + const { enable, disable, isAutostartEnabled } = await import("../tray/autostart.mjs"); + const next = !isAutostartEnabled(); + const ok = next ? enable() : disable(); + emit(next ? { enabled: ok } : { disabled: ok }, globalOpts); + if (!ok) process.exit(1); + }); + + cmd + .command("status", { isDefault: true }) .description(t("autostart.status") || "Show autostart status") .action(async (opts, c) => { const globalOpts = c.optsWithGlobals(); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 18e58b4c2a..857e18b65a 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1228,6 +1228,7 @@ "description": "Manage OmniRoute autostart at boot (Linux: systemd user service)", "enable": "Enable autostart at boot", "disable": "Disable autostart at boot", + "toggle": "Toggle autostart at boot", "status": "Show autostart status" }, "runtime": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 6096dfb087..6ef2702b22 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1228,6 +1228,7 @@ "description": "Gerenciar inicialização automática do OmniRoute no login", "enable": "Habilitar inicialização automática no login", "disable": "Desabilitar inicialização automática no login", + "toggle": "Alternar inicialização automática no login", "status": "Mostrar status de inicialização automática" }, "runtime": { diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 9c868eb317..1cfab7e70b 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 78a81e2c7a..3b29b29bfa 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 78a81e2c7a..3b29b29bfa 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 3bc97dba38..b07767b2c5 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 3f578fd708..e6098ae41c 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 96ac7349d7..2e08ce2cba 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 2ed2228d73..a69c70c8e0 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 8406ee6b70..7e11043430 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index fbc906a2be..255bb62609 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 41128774dd..e2622b2a5c 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 24b06106ec..4f67ca09de 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 5fea96c39d..6df9e404fb 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 6ff1debb5c..8dbc0d6d52 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 8659716fb3..7805c4af00 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 660e13038d..539f22e96f 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index c535886728..625e8ebea5 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 14801a0743..b7dc11289f 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 8c824f3e2b..e6dbca0c37 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 47b2116189..fcefa3572e 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 717d0b5767..b5c0e18a64 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 7680c690b4..3f22ab050f 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index a2d76e7b58..1a67082ce6 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index d0615727d8..89c258d8dd 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index c71a1f9f63..a0c44a40fb 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 51d33bfc5b..30a29f311f 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index ee4e983ce7..f6a3b6a827 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 20802c1888..32fb1ef583 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index faea410a87..136ca1e910 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 91061aa52f..adc758d997 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 9f3e3472e5..a07795f8a1 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 21b026e868..0c25e47a48 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 44d2a4a9f2..791ecf4f83 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 701783bdd0..56dece5f42 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 9342c32e2e..5e8935c520 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index c2062c7253..d3c0814e26 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 31b355a8a1..340f21de7e 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index a2cead6b95..7aa6322885 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index d95ecc00d2..55e35c0467 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 6ea32b06e9..3f29ab46ec 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index c08a7af335..218477da41 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 8bb501c5dc..36d5fb4746 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.21] — 2026-06-11 + +_See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ + +--- + ## [3.8.20] — Unreleased _Development cycle in progress._ diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 8fdda8372c..257f5f2044 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -1287,31 +1287,18 @@ See [Plugins Framework](../plugins/PLUGIN_SDK.md) for full details. ## Shadow Routing -Beta-test new providers without affecting production traffic. - -| Method | Path | Description | -| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | -| GET | `/api/shadow` | List all shadow routing rules | -| POST | `/api/shadow` | Create a shadow routing rule — body: `{providerId, shadowProviderId, trafficPct, duration?}` | -| DELETE | `/api/shadow/[id]` | Delete a shadow routing rule | -| GET | `/api/shadow/[id]/results` | Get shadow routing results (comparison metrics between real and shadow traffic) | -| GET | `/api/shadow/metrics` | Aggregate shadow routing metrics across all rules | - -**Auth:** Requires management session. +Shadow / A-B comparison of providers is **not a standalone REST surface** — it is configured through combo routing (see [Auto-Combo](../routing/AUTO-COMBO.md)). Per-combo comparison metrics are served by `GET /api/combos/metrics`. --- ## Guardrails -Manage runtime guardrails (PII detection, prompt injection detection, vision bridging). +Inspect the runtime guardrails (PII detection, prompt injection detection, vision bridging). Guardrails run on every request; per-call opt-out is via the `x-omniroute-disabled-guardrails` request header — there is no persisted enable/disable surface. -| Method | Path | Description | -| ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | -| GET | `/api/guardrails` | List all guardrails and their status (enabled/disabled) | -| POST | `/api/guardrails/[id]/enable` | Enable a guardrail | -| POST | `/api/guardrails/[id]/disable` | Disable a guardrail | -| GET | `/api/guardrails/logs` | Get guardrail trigger logs (PII detections, injection attempts, etc.) | -| POST | `/api/guardrails/test` | Test a guardrail against a sample input | +| Method | Path | Description | +| ------ | ---------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/guardrails` | List the registered guardrails and their status (name / enabled / priority) | +| POST | `/api/guardrails/test` | Dry-run the pre-call pipeline over a sample input — body: `{input, disabledGuardrails?}` | **Auth:** Requires management session. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3a113de066..b014560092 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -352,6 +352,7 @@ detection above). | `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | | `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | | `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | +| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | | `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | | `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | | `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index ad52734f88..a93fb1dea2 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.20 + version: 3.8.21 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/docs/research/DISCOVERY_TOOL_DESIGN.md b/docs/research/DISCOVERY_TOOL_DESIGN.md index 234390fe17..a0b48c3e5b 100644 --- a/docs/research/DISCOVERY_TOOL_DESIGN.md +++ b/docs/research/DISCOVERY_TOOL_DESIGN.md @@ -83,6 +83,12 @@ CREATE TABLE discovery_results ( ## API Endpoints +> ⚠️ **Not yet implemented — Phase 2 (Future).** The routes below are a design +> proposal, not live endpoints. `src/lib/discovery/index.ts` is an explicit Phase-1 +> stub and none of the discovery routes exist yet. They are intentionally documented +> here as the planned surface; the `check-docs-symbols` quality gate suppresses them +> via `KNOWN_STALE_DOC_REFS` until Phase 2 lands. See **Implementation Plan → Phase 2**. + | Method | Path | Description | |--------|------|-------------| | GET | `/api/discovery/results` | List all discovery results | diff --git a/electron/package.json b/electron/package.json index d6c2290c1f..4d007c3c13 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.20", + "version": "3.8.21", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts index 8ba930782a..d836888b7f 100644 --- a/open-sse/config/agyModels.ts +++ b/open-sse/config/agyModels.ts @@ -1,10 +1,10 @@ // Antigravity CLI (`agy`) model catalog. // -// These IDs were pinned directly from the live `:fetchAvailableModels` endpoint +// These models are pinned from the live `:fetchAvailableModels` endpoint // (https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels) using a -// real `agy` consumer-OAuth token on 2026-05-29. They are the exact upstream model IDs -// the Antigravity backend accepts — no alias remapping is required (unlike the -// `antigravity` provider, whose catalog used display IDs that needed remapping). +// real `agy` consumer-OAuth token. The public catalog exposes the same clean Gemini +// 3.5 Flash tier names as the Antigravity IDE provider; the shared Antigravity executor +// maps those names to the legacy upstream IDs immediately before dispatch. // // The `agy` provider reuses the `antigravity` executor/translator (identical backend), // but ships its OWN catalog so it can expose models the `antigravity` provider's static @@ -62,24 +62,6 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, - { - id: "gemini-3-flash-agent", - name: "Gemini 3.5 Flash (Agent)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, - { - id: "gemini-3-flash", - name: "Gemini 3 Flash", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Low)", @@ -89,10 +71,20 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ toolCalling: true, }, { - id: "gemini-3.5-flash-extra-low", - name: "Gemini 3.5 Flash (Extra Low)", + id: "gemini-3.5-flash-medium", + name: "Gemini 3.5 Flash (Medium)", contextLength: 1048576, maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-high", + name: "Gemini 3.5 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, supportsVision: true, toolCalling: true, }, diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 970fd44ac2..0376d7396d 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -22,10 +22,16 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, - // Gemini 3.5 Flash — flagship model in Antigravity 2.0 (May 2026) + // Gemini 3.5 Flash tiers exposed by Antigravity 2.0.4's model selector. + // The user-facing names are Low / Medium / High, but fetchAvailableModels reports + // legacy upstream IDs for two of them: + // Low -> gemini-3.5-flash-extra-low (displayName: Gemini 3.5 Flash (Low)) + // Medium -> gemini-3.5-flash-low (displayName: Gemini 3.5 Flash (Medium)) + // High -> gemini-3-flash-agent (displayName: Gemini 3.5 Flash (High)) + // Keep the clean public IDs here and map them below for routing/quota. { - id: "gemini-3.5-flash-preview", - name: "Gemini 3.5 Flash", + id: "gemini-3.5-flash-low", + name: "Gemini 3.5 Flash (Low)", contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, @@ -33,8 +39,17 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ toolCalling: true, }, { - id: "gemini-3-flash-agent", - name: "Gemini 3.5 Flash Agent", + id: "gemini-3.5-flash-medium", + name: "Gemini 3.5 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-high", + name: "Gemini 3.5 Flash (High)", contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, @@ -71,33 +86,6 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, - { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsReasoning: true, - supportsVision: true, - toolCalling: true, - }, - // Gemini 3.5 Flash budget tiers — agy ships these as exact upstream ids; #3184 verified - // they work via the antigravity OAuth provider (no alias remapping required). - { - id: "gemini-3.5-flash-low", - name: "Gemini 3.5 Flash (Low)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsVision: true, - toolCalling: true, - }, - { - id: "gemini-3.5-flash-extra-low", - name: "Gemini 3.5 Flash (Extra Low)", - contextLength: 1048576, - maxOutputTokens: 65536, - supportsVision: true, - toolCalling: true, - }, { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", @@ -160,17 +148,20 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ }, ]); -// The Antigravity upstream API uses plain model IDs (no -high/-low suffix). -// The -high/-low suffix convention was speculative and caused 404 for all -// gemini-3.x models. Only plain IDs like "gemini-2.5-flash" are proven working. export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ + "gemini-3.5-flash-low": "gemini-3.5-flash-extra-low", + "gemini-3.5-flash-medium": "gemini-3.5-flash-low", + "gemini-3.5-flash-high": "gemini-3-flash-agent", + // Backward-compat: the retired flagship public id `gemini-3.5-flash-preview` + // (Antigravity 2.0's "Gemini 3.5 Flash") is kept as a HIDDEN alias so saved + // combos/configs keep routing — it maps to the reasoning-capable High tier + // (upstream `gemini-3-flash-agent`). It is NOT re-added to the public catalog. + "gemini-3.5-flash-preview": "gemini-3-flash-agent", "gemini-3-pro-preview": "gemini-3.1-pro", // agy catalog exposes -high/-low budget tiers, but the upstream rejects the suffix // for gemini-3.x (#3229) — map them to the plain proven id. "gemini-3.1-pro-high": "gemini-3.1-pro", "gemini-3.1-pro-low": "gemini-3.1-pro", - "gemini-3.5-flash-preview": "gemini-3.5-flash", - "gemini-3-flash-preview": "gemini-3-flash", "gemini-3-pro-image-preview": "gemini-3-pro-image", "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p", // Legacy Claude display ids → current upstream ids. NOTE: an earlier comment here @@ -186,10 +177,9 @@ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ type AntigravityModelAliasMap = Record; export const ANTIGRAVITY_REVERSE_MODEL_ALIASES: AntigravityModelAliasMap = Object.freeze({ + "gemini-3.5-flash-extra-low": "gemini-3.5-flash-low", + "gemini-3-flash-agent": "gemini-3.5-flash-high", "gemini-3.1-pro": "gemini-3-pro-preview", - "gemini-3.5-flash": "gemini-3.5-flash-preview", - "gemini-3-flash-agent": "gemini-3.5-flash-preview", - "gemini-3-flash": "gemini-3-flash-preview", "gemini-3-pro-image": "gemini-3-pro-image-preview", "rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025", }); @@ -216,6 +206,43 @@ export function toClientAntigravityModelId(modelId: string): string { return ANTIGRAVITY_REVERSE_MODEL_ALIASES[modelId] || modelId; } +// Quota buckets reported by the Antigravity backend are keyed by UPSTREAM model ids — a +// DIFFERENT namespace from the public/client catalog. In that upstream quota namespace +// `gemini-3.5-flash-low` denotes the *Medium* tier's bucket (it is the upstream target of +// the `gemini-3.5-flash-medium` forward alias), even though the same literal is also a +// public "Low" client id. This remap therefore CANNOT be derived from +// ANTIGRAVITY_REVERSE_MODEL_ALIASES (which has no `gemini-3.5-flash-low` entry precisely +// because it is already a valid client id) — it encodes the upstream-bucket → client-tier +// chain explicitly. Keep it the inverse of the `-low/-medium/-high` rows in +// ANTIGRAVITY_MODEL_ALIASES above. (#3821-review LEDGER-5 — was duplicated as an inline +// if-ladder in open-sse/services/usage.ts.) +const ANTIGRAVITY_QUOTA_BUCKET_TO_CLIENT: AntigravityModelAliasMap = Object.freeze({ + "gemini-3.5-flash-extra-low": "gemini-3.5-flash-low", + "gemini-3.5-flash-low": "gemini-3.5-flash-medium", + "gemini-3-flash-agent": "gemini-3.5-flash-high", +}); + +// Retired/hidden upstream preview buckets that must be dropped from client-facing usage. +const ANTIGRAVITY_DROPPED_QUOTA_BUCKETS = new Set([ + "gemini-3.5-flash-preview", + "gemini-3-flash-preview", +]); + +/** + * Map an UPSTREAM Antigravity quota-bucket model id to the client-visible tier id used in + * usage responses, or `null` if the bucket should be hidden from clients. Operates on the + * upstream quota namespace (see ANTIGRAVITY_QUOTA_BUCKET_TO_CLIENT) — do NOT pass client + * ids here. Single source of truth shared by the usage service and the provider-limits + * cache sanitizer. + */ +export function toClientAntigravityQuotaModelId(modelId: string): string | null { + if (!modelId) return null; + if (ANTIGRAVITY_DROPPED_QUOTA_BUCKETS.has(modelId)) return null; + const tierClientId = ANTIGRAVITY_QUOTA_BUCKET_TO_CLIENT[modelId]; + if (tierClientId) return tierClientId; + return toClientAntigravityModelId(modelId); +} + export function getClientVisibleAntigravityModelName( modelId: string, fallbackName?: string diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 4638eda889..278593bac2 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -656,7 +656,7 @@ const _REGISTRY_EAGER: Record = { }, oauth: { clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID", - clientIdDefault: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + clientIdDefault: resolvePublicCred("claude_id"), tokenUrl: "https://api.anthropic.com/v1/oauth/token", }, models: [ @@ -834,7 +834,7 @@ const _REGISTRY_EAGER: Record = { headers: getCodexDefaultHeaders(), oauth: { clientIdEnv: "CODEX_OAUTH_CLIENT_ID", - clientIdDefault: "app_EMoamEEZ73f0CkXaXp7hrann", + clientIdDefault: resolvePublicCred("codex_id"), clientSecretEnv: "CODEX_OAUTH_CLIENT_SECRET", clientSecretDefault: "", tokenUrl: "https://auth.openai.com/oauth/token", @@ -928,7 +928,7 @@ const _REGISTRY_EAGER: Record = { headers: getQwenOauthHeaders(), oauth: { clientIdEnv: "QWEN_OAUTH_CLIENT_ID", - clientIdDefault: "f0304373b74a44d2b584a3fb70ca9e56", + clientIdDefault: resolvePublicCred("qwen_id"), tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", authUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", }, @@ -1970,7 +1970,7 @@ const _REGISTRY_EAGER: Record = { authType: "oauth", oauth: { clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID", - clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098", + clientIdDefault: resolvePublicCred("kimi_id"), tokenUrl: "https://auth.kimi.com/api/oauth/token", refreshUrl: "https://auth.kimi.com/api/oauth/token", authUrl: "https://auth.kimi.com/api/oauth/device_authorization", diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index d9267e0a7e..cd0f50fe50 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -194,7 +194,7 @@ function addAntigravityTextualToolCall( type AntigravityRequestEnvelope = Record & { project: string; - model: string; + model?: string; userAgent: "antigravity" | "jetski"; requestType: "agent" | "image_gen"; requestId: string; @@ -328,7 +328,11 @@ function markCreditsExhausted(accountId: string): void { creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS); } -function processAntigravitySSEPayload( +/** + * Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit + * tests (the markdown / candidate-parts extraction branches). @internal + */ +export function processAntigravitySSEPayload( payload: string, collected: AntigravityCollectedStream, log?: { debug?: (scope: string, message: string) => void } @@ -336,6 +340,15 @@ function processAntigravitySSEPayload( if (!payload || payload === "[DONE]") return; try { const parsed = JSON.parse(payload); + const markdown = + typeof parsed?.markdown === "string" + ? parsed.markdown + : typeof parsed?.response?.markdown === "string" + ? parsed.response.markdown + : null; + if (markdown) { + collected.textContent += markdown; + } const candidate = parsed?.response?.candidates?.[0]; if (candidate?.content?.parts) { for (const part of candidate.content.parts) { @@ -661,9 +674,14 @@ export class AntigravityExecutor extends BaseExecutor { if (typeof p.text === "string" && p.text === "") return false; if (p.functionCall && !p.functionCall.name) return false; - // Only strip if it's NOT our bypass sentinel. + // Only strip if it's NOT our bypass sentinel. // Antigravity models (like Gemini) need this sentinel to bypass 400 errors. - return !p.thought && (hasFunctionCall || !p.thoughtSignature || p.thoughtSignature === "skip_thought_signature_validator"); + return ( + !p.thought && + (hasFunctionCall || + !p.thoughtSignature || + p.thoughtSignature === "skip_thought_signature_validator") + ); }) || []; return { ...c, role, parts }; }) || []; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2f911bfb6c..cf8008f5c4 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1,3 +1,7 @@ +import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts"; +import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; +import { checkSemanticCache } from "./chatCore/semanticCache.ts"; +import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; import { HEAP_PRESSURE_THRESHOLD_MB } from "../utils/heapPressure.ts"; import { normalizeHeaders } from "../utils/headers.ts"; @@ -176,7 +180,7 @@ import { isCacheableForRead, isCacheableForWrite, } from "@/lib/semanticCache"; -import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer"; +import { saveIdempotency } from "@/lib/idempotencyLayer"; import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts"; import { createPiiSseTransform } from "@/lib/streamingPiiTransform"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; @@ -209,14 +213,6 @@ import { import { generateRequestId } from "@/shared/utils/requestId"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import { extractFacts } from "@/lib/memory/extraction"; -import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection"; -import { retrieveMemories } from "@/lib/memory/retrieval"; -import { - DEFAULT_MEMORY_SETTINGS, - getMemorySettings, - toMemoryRetrievalConfig, -} from "@/lib/memory/settings"; -import { injectSkills } from "@/lib/skills/injection"; import { handleToolCallExecution } from "@/lib/skills/interception"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; import { @@ -1871,39 +1867,18 @@ export async function handleChatCore({ }; // ── Phase 9.2: Idempotency check ── - const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers); - const cachedIdemp = checkIdempotency(idempotencyKey); - if (cachedIdemp) { - log?.debug?.("IDEMPOTENCY", `Hit for key=${idempotencyKey?.slice(0, 12)}...`); - const idempotentUsage = - cachedIdemp.response && typeof cachedIdemp.response === "object" - ? ((cachedIdemp.response as Record).usage as - | Record - | undefined) - : undefined; - const idempotentCost = idempotentUsage - ? await calculateCost(provider, model, idempotentUsage as Record, { - serviceTier: effectiveServiceTier, - }) - : 0; - return { - success: true, - response: new Response(JSON.stringify(cachedIdemp.response), { - status: cachedIdemp.status, - headers: { - "Content-Type": "application/json", - "X-OmniRoute-Idempotent": "true", - ...buildOmniRouteResponseMetaHeaders({ - provider, - model, - cacheHit: false, - latencyMs: Date.now() - startTime, - usage: idempotentUsage, - costUsd: idempotentCost, - }), - }, - }), - }; + // Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below, + // rather than re-deriving it. (#3821-review LEDGER-6) + const { hit: idempotencyHit, idempotencyKey } = await checkIdempotencyCache({ + clientRawRequest, + provider, + model, + effectiveServiceTier, + startTime, + log, + }); + if (idempotencyHit) { + return idempotencyHit; } // T07: Inject connectionId into credentials so executors can rotate API keys @@ -2303,269 +2278,38 @@ export async function handleChatCore({ log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); // ── Phase 9.1: Semantic cache check (temp=0, any streaming mode) ── - // Streaming responses are cached after assembly; cache hits return JSON regardless of stream flag. - if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { - const signature = generateSignature( - model, - body.messages ?? body.input, - body.temperature, - body.top_p - ); - const cached = getCachedResponse(signature); - if (cached) { - log?.debug?.("CACHE", `Semantic cache HIT for ${model} (stream=${stream})`); - reqLogger.logConvertedResponse(cached as Record); - const cachedUsage = - extractUsageFromResponse(cached as Record, provider) || - ((cached as Record)?.usage as Record | undefined); - const cachedCost = cachedUsage - ? await calculateCost(provider, model, cachedUsage as Record, { - serviceTier: effectiveServiceTier, - }) - : 0; - persistAttemptLogs({ - status: 200, - tokens: (cached as Record)?.usage, - responseBody: cached, - providerRequest: null, - providerResponse: null, - clientResponse: cached, - cacheSource: "semantic", - }); - trackPendingRequest(model, provider, connectionId, false); - // #2952 — when the client requested a stream, serve the cached completion - // as an SSE stream (not a raw JSON body) so content + reasoning_content - // arrive in the streaming shape the client expects. Cache hits previously - // returned application/json regardless of the stream flag, which made - // OpenAI-compatible streaming clients lose reasoning_content. Non-OpenAI - // shapes (no `choices`) yield "" and fall back to the JSON body unchanged. - const cachedSse = stream ? synthesizeOpenAiSseFromJson(JSON.stringify(cached)) : ""; - const cacheHitMetaHeaders = buildOmniRouteResponseMetaHeaders({ - provider, - model, - cacheHit: true, - latencyMs: Date.now() - startTime, - usage: cachedUsage, - costUsd: cachedCost, - }); - return { - success: true, - response: new Response(cachedSse || JSON.stringify(cached), { - headers: { - "Content-Type": cachedSse ? "text/event-stream" : "application/json", - [OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT", - ...cacheHitMetaHeaders, - }, - }), - }; - } - } - - // ── Common input sanitization (runs for ALL paths including passthrough) ── - // #994: Normalize between max_output_tokens and max_tokens for universal compatibility. - // For Responses API targets, max_output_tokens is the canonical field. For others, - // max_tokens is preferred. We handle normalization here to support passthrough - // paths where the translator is skipped. - const prefersResponsesTokenField = - sourceFormat === FORMATS.OPENAI_RESPONSES || targetFormat === FORMATS.OPENAI_RESPONSES; - - if (prefersResponsesTokenField) { - if (body.max_output_tokens === undefined) { - if (body.max_completion_tokens !== undefined) { - body.max_output_tokens = body.max_completion_tokens; - delete body.max_completion_tokens; - } else if (body.max_tokens !== undefined) { - body.max_output_tokens = body.max_tokens; - delete body.max_tokens; - } - } - } else if (body.max_output_tokens !== undefined) { - if (body.max_tokens === undefined) { - body.max_tokens = body.max_output_tokens; - } - delete body.max_output_tokens; - } - - // #291: Strip empty name fields from messages/input items - // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. - if (Array.isArray(body.messages)) { - body.messages = body.messages.map((msg: Record) => { - if (msg.name === "") { - const { name: _n, ...rest } = msg; - return rest; - } - return msg; - }); - } - if (Array.isArray(body.input)) { - body.input = body.input.map((item: Record) => { - if (item.name === "") { - const { name: _n, ...rest } = item; - return rest; - } - return item; - }); - } - // #346/#637: Strip tools with empty name - // Clients sometimes forward tool definitions with empty names, causing - // upstream providers to reject with 400 "Invalid 'tools[0].name': empty string." - if (Array.isArray(body.tools)) { - body.tools = body.tools.filter((tool: Record) => { - // Built-in Responses API tool types (web_search, file_search, computer, etc.) - // are identified solely by their `type` field and carry no name — preserve them. - const toolType = typeof tool.type === "string" ? tool.type : ""; - if (toolType && toolType !== "function" && !tool.function && tool.name === undefined) { - return true; - } - const fn = tool.function as Record | undefined; - const name = fn?.name ?? tool.name; - return name && String(name).trim().length > 0; - }); - - // Sanitize OpenAI-format function tool schemas before they reach strict - // upstream JSON Schema validators (e.g. Moonshot AI behind - // opencode-go/kimi-k2.6). See toolSchemaSanitizer.ts for the specific bug. - // sanitizeOpenAITool is safe to call on any input — it no-ops non-function - // tools (e.g. Responses API built-ins) and non-object values. - body.tools = body.tools.map((tool) => sanitizeOpenAITool(tool) as (typeof body.tools)[number]); + const cacheHit = await checkSemanticCache({ + semanticCacheEnabled, + body, + clientRawRequest, + model, + provider, + stream: !!stream, + reqLogger, + effectiveServiceTier, + connectionId, + startTime, + log, + persistAttemptLogs, + }); + if (cacheHit) { + return cacheHit; } + body = sanitizeChatRequestBody(body, sourceFormat, targetFormat); const memoryOwnerId = resolveMemoryOwnerId(apiKeyInfo as Record | null); - const memorySettings = memoryOwnerId - ? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS) - : null; - - if ( - memoryOwnerId && - memorySettings && - shouldInjectMemory(body as Parameters[0], { - enabled: memorySettings.enabled && memorySettings.maxTokens > 0, - }) - ) { - try { - // Plan 21 FAIL #1 fix: extract the last user message and pass it as - // `query`. Without this, `config.query` is undefined in retrieveMemories - // and the semantic/hybrid branches (sqlite-vec + RRF, and Qdrant - // tier-2) never fire from the chat hot path — they only fire in the - // Playground (retrievePreview, which gets `query` as a positional arg). - const lastUserQuery = ((): string => { - // Responses API item types that are NOT user input — never accept - // their text as the retrieval query (e.g. function_call_output is the - // tool's reply, reasoning is the model's chain of thought). - const NON_USER_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 pickFrom(arr: unknown[]): string { - for (let i = arr.length - 1; i >= 0; i--) { - const item = arr[i] as Record | undefined; - if (!item) continue; - // Chat API: only role==="user" items. Responses API items often - // have type instead of role — skip non-user types like - // function_call_output so the tool's reply doesn't leak into the - // memory query. - if (item.role !== undefined && item.role !== "user") continue; - if (item.role === undefined && typeof item.type === "string") { - if (NON_USER_TYPES.has(item.type)) continue; - } - const content = item.content ?? item.text; - if (typeof content === "string" && content.trim().length > 0) { - return content; - } - if (Array.isArray(content)) { - const parts: string[] = []; - for (const p of content) { - if (typeof p === "string") { - parts.push(p); - } else if (p && typeof p === "object") { - const pp = p as Record; - // Skip non-text content parts (image_url, tool_use, etc.) - const ptype = typeof pp.type === "string" ? pp.type : ""; - if ( - ptype && - ptype !== "text" && - ptype !== "input_text" && - ptype !== "output_text" - ) { - continue; - } - const t = pp.text ?? pp.input_text; - if (typeof t === "string") parts.push(t); - } - } - if (parts.length > 0) return parts.join(" ").trim(); - } - } - return ""; - } - const b = body as Record; - if (Array.isArray(b.messages)) { - const r = pickFrom(b.messages); - if (r) return r; - } - if (Array.isArray(b.input)) { - const r = pickFrom(b.input); - if (r) return r; - } - return ""; - })(); - - const memories = await retrieveMemories( - memoryOwnerId, - toMemoryRetrievalConfig(memorySettings, { query: lastUserQuery }) - ); - if (memories.length > 0) { - const injected = injectMemory( - body as Parameters[0], - memories, - provider - ); - body = injected as typeof body; - log?.debug?.("MEMORY", `Injected ${memories.length} memories for key=${memoryOwnerId}`); - } - } catch (memErr) { - log?.debug?.( - "MEMORY", - `Memory injection skipped: ${memErr instanceof Error ? memErr.message : String(memErr)}` - ); - } - } - - if (memoryOwnerId && memorySettings?.skillsEnabled) { - const existingTools = Array.isArray(body.tools) ? body.tools : []; - const mergedTools = injectSkills({ - provider: getSkillsProviderForFormat(sourceFormat), - existingTools, - apiKeyId: memoryOwnerId, - model: typeof effectiveModel === "string" ? effectiveModel : undefined, - sourceFormat, - targetFormat, - backgroundReason, - messages: Array.isArray(body.messages) - ? body.messages - : Array.isArray(body.input) - ? body.input - : undefined, - }); - - if (mergedTools.length > existingTools.length) { - body = { - ...body, - tools: mergedTools, - }; - log?.debug?.("SKILLS", `Injected ${mergedTools.length - existingTools.length} skills`); - } - } - - trace("post_injection", { provider, model }); + const injectionResult = await injectMemoryAndSkills({ + body, + memoryOwnerId, + provider, + effectiveModel, + sourceFormat, + targetFormat, + backgroundReason, + log, + }); + body = injectionResult.body; + const memorySettings = injectionResult.memorySettings; // Translate request (pass reqLogger for intermediate logging) // ── Proactive Context Compression (Phase 4) ── @@ -5490,6 +5234,8 @@ export async function handleChatCore({ } // ── Phase 9.2: Save for idempotency ── + // Reuse the key resolved by checkIdempotencyCache() above (single derivation per + // request). (#3821-review LEDGER-6) saveIdempotency(idempotencyKey, translatedResponse, 200); reqLogger.logConvertedResponse(translatedResponse); persistAttemptLogs({ diff --git a/open-sse/handlers/chatCore/idempotency.ts b/open-sse/handlers/chatCore/idempotency.ts new file mode 100644 index 0000000000..35a4b3c101 --- /dev/null +++ b/open-sse/handlers/chatCore/idempotency.ts @@ -0,0 +1,64 @@ +import { getIdempotencyKey, checkIdempotency } from "@/lib/idempotencyLayer"; +import { calculateCost } from "@/lib/usage/costCalculator"; +import { buildOmniRouteResponseMetaHeaders } from "@/domain/omnirouteResponseMeta"; + +/** + * Resolve the request's idempotency key once and check the idempotency store. Returns the + * resolved `idempotencyKey` alongside the cache `hit` so the caller can reuse the SAME key + * for the later save path instead of re-deriving it — eliminating the dual-derivation that + * the chatCore modularization (#3598) introduced. (#3821-review LEDGER-6) + */ +export async function checkIdempotencyCache({ + clientRawRequest, + provider, + model, + effectiveServiceTier, + startTime, + log, +}: { + clientRawRequest: unknown; + provider: string; + model: string; + effectiveServiceTier: unknown; + startTime: number; + log: unknown; +}): Promise<{ hit: { success: true; response: Response } | null; idempotencyKey: string }> { + const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers); + const cachedIdemp = checkIdempotency(idempotencyKey); + if (cachedIdemp) { + log?.debug?.("IDEMPOTENCY", `Hit for key=${idempotencyKey?.slice(0, 12)}...`); + const idempotentUsage = + cachedIdemp.response && typeof cachedIdemp.response === "object" + ? ((cachedIdemp.response as Record).usage as + | Record + | undefined) + : undefined; + const idempotentCost = idempotentUsage + ? await calculateCost(provider, model, idempotentUsage as Record, { + serviceTier: effectiveServiceTier, + }) + : 0; + return { + idempotencyKey, + hit: { + success: true, + response: new Response(JSON.stringify(cachedIdemp.response), { + status: cachedIdemp.status, + headers: { + "Content-Type": "application/json", + "X-OmniRoute-Idempotent": "true", + ...buildOmniRouteResponseMetaHeaders({ + provider, + model, + cacheHit: false, + latencyMs: Date.now() - startTime, + usage: idempotentUsage, + costUsd: idempotentCost, + }), + }, + }), + }, + }; + } + return { hit: null, idempotencyKey }; +} diff --git a/open-sse/handlers/chatCore/memorySkillsInjection.ts b/open-sse/handlers/chatCore/memorySkillsInjection.ts new file mode 100644 index 0000000000..28719d810d --- /dev/null +++ b/open-sse/handlers/chatCore/memorySkillsInjection.ts @@ -0,0 +1,159 @@ +import { retrieveMemories } from "@/lib/memory/retrieval"; +import { getMemorySettings, DEFAULT_MEMORY_SETTINGS, toMemoryRetrievalConfig } from "@/lib/memory/settings"; +import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection"; +import { injectSkills } from "@/lib/skills/injection"; +import { FORMATS } from "../../translator/formats.ts"; + +export function getSkillsProviderForFormat(format: string): "openai" | "anthropic" | "google" | "other" { + switch (format) { + case FORMATS.CLAUDE: + return "anthropic"; + case FORMATS.GEMINI: + return "google"; + default: + return "openai"; + } +} + +export async function injectMemoryAndSkills({ + body, + memoryOwnerId, + provider, + effectiveModel, + sourceFormat, + targetFormat, + backgroundReason, + log, +}: { + body: Record; + memoryOwnerId: string | null; + provider: string; + effectiveModel: string; + sourceFormat: string; + targetFormat: string; + backgroundReason: string | null; + log: unknown; +}) { + const memorySettings = memoryOwnerId + ? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS) + : null; + + if ( + memoryOwnerId && + memorySettings && + shouldInjectMemory(body as Parameters[0], { + enabled: memorySettings.enabled && memorySettings.maxTokens > 0, + }) + ) { + try { + const lastUserQuery = ((): string => { + const NON_USER_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 pickFrom(arr: unknown[]): string { + for (let i = arr.length - 1; i >= 0; i--) { + const item = arr[i] as Record | undefined; + if (!item) continue; + if (item.role !== undefined && item.role !== "user") continue; + if (item.role === undefined && typeof item.type === "string") { + if (NON_USER_TYPES.has(item.type)) continue; + } + const content = item.content ?? item.text; + if (typeof content === "string" && content.trim().length > 0) { + return content; + } + if (Array.isArray(content)) { + const parts: string[] = []; + for (const p of content) { + if (typeof p === "string") { + parts.push(p); + } else if (p && typeof p === "object") { + const pp = p as Record; + const ptype = typeof pp.type === "string" ? pp.type : ""; + if ( + ptype && + ptype !== "text" && + ptype !== "input_text" && + ptype !== "output_text" + ) { + continue; + } + const t = pp.text ?? pp.input_text; + if (typeof t === "string") parts.push(t); + } + } + if (parts.length > 0) return parts.join(" ").trim(); + } + } + return ""; + } + + if (Array.isArray(body.messages)) { + const r = pickFrom(body.messages); + if (r) return r; + } + if (Array.isArray(body.input)) { + const r = pickFrom(body.input); + if (r) return r; + } + return ""; + })(); + + const memories = await retrieveMemories( + memoryOwnerId, + toMemoryRetrievalConfig(memorySettings, { query: lastUserQuery }) + ); + if (memories.length > 0) { + const injected = injectMemory( + body as Parameters[0], + memories, + provider + ); + body = injected as typeof body; + log?.debug?.("MEMORY", `Injected ${memories.length} memories for key=${memoryOwnerId}`); + } + } catch (memErr) { + log?.debug?.( + "MEMORY", + `Memory injection skipped: ${memErr instanceof Error ? memErr.message : String(memErr)}` + ); + } + } + + if (memoryOwnerId && memorySettings?.skillsEnabled) { + const existingTools = Array.isArray(body.tools) ? body.tools : []; + const mergedTools = injectSkills({ + provider: getSkillsProviderForFormat(sourceFormat), + existingTools, + apiKeyId: memoryOwnerId, + model: typeof effectiveModel === "string" ? effectiveModel : undefined, + sourceFormat, + targetFormat, + backgroundReason, + messages: Array.isArray(body.messages) + ? body.messages + : Array.isArray(body.input) + ? body.input + : undefined, + }); + + if (mergedTools.length > existingTools.length) { + body = { + ...body, + tools: mergedTools, + }; + log?.debug?.("SKILLS", `Injected ${mergedTools.length - existingTools.length} skills`); + } + } + + return { body, memorySettings }; +} diff --git a/open-sse/handlers/chatCore/sanitization.ts b/open-sse/handlers/chatCore/sanitization.ts new file mode 100644 index 0000000000..62b43615ed --- /dev/null +++ b/open-sse/handlers/chatCore/sanitization.ts @@ -0,0 +1,63 @@ +import { FORMATS } from "../../translator/formats.ts"; +import { sanitizeOpenAITool } from "../../services/toolSchemaSanitizer.ts"; + +export function sanitizeChatRequestBody( + body: Record, + sourceFormat: string, + targetFormat: string +): Record { + const prefersResponsesTokenField = + sourceFormat === FORMATS.OPENAI_RESPONSES || targetFormat === FORMATS.OPENAI_RESPONSES; + + if (prefersResponsesTokenField) { + if (body.max_output_tokens === undefined) { + if (body.max_completion_tokens !== undefined) { + body.max_output_tokens = body.max_completion_tokens; + delete body.max_completion_tokens; + } else if (body.max_tokens !== undefined) { + body.max_output_tokens = body.max_tokens; + delete body.max_tokens; + } + } + } else if (body.max_output_tokens !== undefined) { + if (body.max_tokens === undefined) { + body.max_tokens = body.max_output_tokens; + } + delete body.max_output_tokens; + } + + if (Array.isArray(body.messages)) { + body.messages = body.messages.map((msg: Record) => { + if (msg.name === "") { + const { name: _n, ...rest } = msg; + return rest; + } + return msg; + }); + } + if (Array.isArray(body.input)) { + body.input = body.input.map((item: Record) => { + if (item.name === "") { + const { name: _n, ...rest } = item; + return rest; + } + return item; + }); + } + + if (Array.isArray(body.tools)) { + body.tools = body.tools.filter((tool: Record) => { + const toolType = typeof tool.type === "string" ? tool.type : ""; + if (toolType && toolType !== "function" && !tool.function && tool.name === undefined) { + return true; + } + const fn = tool.function as Record | undefined; + const name = fn?.name ?? tool.name; + return name && String(name).trim().length > 0; + }); + + body.tools = body.tools.map((tool) => sanitizeOpenAITool(tool) as (typeof body.tools)[number]); + } + + return body; +} diff --git a/open-sse/handlers/chatCore/semanticCache.ts b/open-sse/handlers/chatCore/semanticCache.ts new file mode 100644 index 0000000000..9ce133c1a2 --- /dev/null +++ b/open-sse/handlers/chatCore/semanticCache.ts @@ -0,0 +1,91 @@ +import { + generateSignature, + getCachedResponse, + isCacheableForRead, +} from "@/lib/semanticCache"; +import { calculateCost } from "@/lib/usage/costCalculator"; +import { trackPendingRequest } from "@/lib/usageDb"; +import { synthesizeOpenAiSseFromJson } from "../../utils/jsonToSse.ts"; +import { buildOmniRouteResponseMetaHeaders } from "@/domain/omnirouteResponseMeta"; +import { extractUsageFromResponse } from "../usageExtractor.ts"; +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; + +export async function checkSemanticCache({ + semanticCacheEnabled, + body, + clientRawRequest, + model, + provider, + stream, + reqLogger, + effectiveServiceTier, + connectionId, + startTime, + log, + persistAttemptLogs, +}: { + semanticCacheEnabled: boolean; + body: Record; + clientRawRequest: unknown; + model: string; + provider: string; + stream: boolean; + reqLogger: unknown; + effectiveServiceTier: unknown; + connectionId: string | null; + startTime: number; + log: unknown; + persistAttemptLogs: (args: unknown) => void; +}) { + if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { + const signature = generateSignature( + model, + body.messages ?? body.input, + body.temperature, + body.top_p + ); + const cached = getCachedResponse(signature); + if (cached) { + log?.debug?.("CACHE", `Semantic cache HIT for ${model} (stream=${stream})`); + reqLogger.logConvertedResponse(cached as Record); + const cachedUsage = + extractUsageFromResponse(cached as Record, provider) || + ((cached as Record)?.usage as Record | undefined); + const cachedCost = cachedUsage + ? await calculateCost(provider, model, cachedUsage as Record, { + serviceTier: effectiveServiceTier, + }) + : 0; + persistAttemptLogs({ + status: 200, + tokens: (cached as Record)?.usage, + responseBody: cached, + providerRequest: null, + providerResponse: null, + clientResponse: cached, + cacheSource: "semantic", + }); + trackPendingRequest(model, provider, connectionId, false); + const cachedSse = stream ? synthesizeOpenAiSseFromJson(JSON.stringify(cached)) : ""; + const cacheHitMetaHeaders = buildOmniRouteResponseMetaHeaders({ + provider, + model, + cacheHit: true, + latencyMs: Date.now() - startTime, + usage: cachedUsage, + costUsd: cachedCost, + }); + return { + success: true, + response: new Response(cachedSse || JSON.stringify(cached), { + headers: { + "Content-Type": cachedSse ? "text/event-stream" : "application/json", + [OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT", + ...cacheHitMetaHeaders, + }, + }), + }; + } + } + return null; +} diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 2c26a06480..112673127a 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -200,8 +200,20 @@ function hasVisibleMessageContent(content: unknown): boolean { }); } -// Matches ... blocks and ... (greedy, dotAll) -const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi; +const REASONING_TAG_NAMES = ["think", "thinking", "thought", "internal_thought"]; +const REASONING_TAG_PATTERN = REASONING_TAG_NAMES.join("|"); +// Matches complete /// blocks. +const THINK_TAG_REGEX = new RegExp( + `<(${REASONING_TAG_PATTERN})\\b[^>]*>([\\s\\S]*?)<\\/\\1>`, + "gi" +); +// Matches an unclosed reasoning tag at the end of a message. Some providers can +// emit malformed/open reasoning wrappers (for example "]*)?(?:>|\\r?\\n)([\\s\\S]*)$`, + "i" +); // #638, #727: Collapse runs of 2+ consecutive newlines into \n\n // Tool call responses from thinking models often accumulate excessive newlines @@ -225,7 +237,7 @@ export function extractThinkingFromContent(text: string): { const thinkingParts: string[] = []; let hasThinkTags = false; - const cleaned = text.replace(THINK_TAG_REGEX, (_, thinkContent) => { + let cleaned = text.replace(THINK_TAG_REGEX, (_match, _tagName, thinkContent) => { hasThinkTags = true; const trimmed = thinkContent.trim(); if (trimmed) { @@ -234,6 +246,15 @@ export function extractThinkingFromContent(text: string): { return ""; }); + const unclosedMatch = cleaned.match(UNCLOSED_REASONING_TAG_REGEX); + if (unclosedMatch?.index !== undefined) { + hasThinkTags = true; + const reasoning = String(unclosedMatch[2] || "").trim(); + if (reasoning) thinkingParts.push(reasoning); + const prefix = cleaned.slice(0, unclosedMatch.index); + cleaned = /^(?:\s|§\d+§)*$/.test(prefix) ? "" : prefix; + } + if (!hasThinkTags) { return { content: text, thinking: null }; } @@ -987,7 +1008,9 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { // Keep only standard fields — normalize id to string to avoid AI_InvalidResponseDataError if (parsedRecord.id !== undefined && parsedRecord.id !== null) { - sanitized.id = normalizeResponseId(typeof parsedRecord.id === "string" ? parsedRecord.id : String(parsedRecord.id)); + sanitized.id = normalizeResponseId( + typeof parsedRecord.id === "string" ? parsedRecord.id : String(parsedRecord.id) + ); } sanitized.object = toString(parsedRecord.object) || "chat.completion.chunk"; if (parsedRecord.created !== undefined) sanitized.created = parsedRecord.created; diff --git a/open-sse/package.json b/open-sse/package.json index 09ebc933a8..d3ac81a28d 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.20", + "version": "3.8.21", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 45d7180f2a..acc302858c 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -6,6 +6,13 @@ import { } from "./accountFallback.ts"; import { getProviderCategory } from "../config/providerRegistry.ts"; +// Terminal stop signals where an empty content payload is still a legitimate, +// successful completion (truncated at the token limit, or a tool-call turn) — +// NOT a silent "fake success" failure. Used to avoid rewriting a valid HTTP 200 +// (e.g. a Claude Code `max_tokens: 1` connectivity ping) into a synthetic 502. +const LEGIT_EMPTY_CLAUDE_STOP = new Set(["max_tokens", "tool_use"]); +const LEGIT_EMPTY_OPENAI_FINISH = new Set(["length", "tool_calls"]); + export function isEmptyContentResponse(responseBody: unknown): boolean { if (!responseBody || typeof responseBody !== "object") return false; @@ -28,11 +35,22 @@ export function isEmptyContentResponse(responseBody: unknown): boolean { const hasReasoning = reasoningContent !== null && reasoningContent !== undefined && reasoningContent !== ""; + // A response truncated at the token limit (finish_reason "length") is a valid, + // successful completion even with empty text — do not flag it as a fake success. + const finishReason = + typeof firstChoice.finish_reason === "string" ? firstChoice.finish_reason : ""; + if (LEGIT_EMPTY_OPENAI_FINISH.has(finishReason)) return false; + return !hasContent && !hasReasoning && !hasToolCalls; } if (Array.isArray(body.content)) { - return body.content.length === 0; + if (body.content.length > 0) return false; + // Empty content array: a response truncated at max_tokens (or one that stopped + // to emit a tool_use block) is a legitimate terminal state, not a silent + // failure. Only flag empty content when no such terminal stop_reason is present. + const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : ""; + return !LEGIT_EMPTY_CLAUDE_STOP.has(stopReason); } if (typeof body.text === "string") { diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index e870f708f3..ec0f0844ff 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,5 +1,4 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; -import { ANTIGRAVITY_MODEL_ALIASES } from "../config/antigravityModelAliases.ts"; import { resolveWildcardAlias } from "./wildcardRouter.ts"; type ProviderModelAliasMap = Record>; @@ -76,7 +75,12 @@ const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = { "gpt-oss-20b": "openai/gpt-oss-20b", "nvidia/gpt-oss-20b": "openai/gpt-oss-20b", }, - antigravity: { ...ANTIGRAVITY_MODEL_ALIASES }, + // Antigravity model aliases must be applied by the Antigravity executor, not by + // the global model resolver. Applying them here rewrites the client-visible model + // before credential/account routing and before UI/logging, causing clean IDs like + // gemini-3.5-flash-high to be exposed and retried as upstream-only legacy ids such + // as gemini-3-flash-agent. The executor owns provider-wire normalization. + antigravity: {}, kiro: { "claude-opus-4-7": "claude-opus-4.7", "claude-opus-4-6": "claude-opus-4.6", diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b951ebb1c7..38336b2947 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -7,16 +7,18 @@ import { getAntigravityFetchAvailableModelsUrls, ANTIGRAVITY_BASE_URLS, } from "../config/antigravityUpstream.ts"; -import { isUserCallableAntigravityModelId } from "../config/antigravityModelAliases.ts"; +import { + isUserCallableAntigravityModelId, + toClientAntigravityQuotaModelId, +} from "../config/antigravityModelAliases.ts"; +import { isUserCallableAgyModelId } from "../config/agyModels.ts"; import { getGlmQuotaUrl } from "../config/glmProvider.ts"; import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts"; import { safePercentage } from "@/shared/utils/formatting"; +import { getDbInstance } from "@/lib/db/core"; import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; -import { - fetchOpencodeQuota, - type OpencodeTripleWindowQuota, -} from "./opencodeQuotaFetcher.ts"; +import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; import { applyAntigravityClientProfileHeaders, getAntigravityBootstrapHeaders, @@ -139,6 +141,7 @@ type UsageQuota = { * NOT a confirmed-exhausted state. Antigravity-specific. */ fractionReported?: boolean; + quotaSource?: "retrieveUserQuota" | "fetchAvailableModels" | "localUsageHistory"; displayName?: string; details?: Array<{ name: string; @@ -812,6 +815,20 @@ function orderGlmQuotas(quotas: Record): Record 0`) it is `remaining / total`. Coding plans that have no + * monthly cap (only 5-hour windows) report `total = 0`; in that case fall back to the + * percentage-derived remaining so "no monthly cap" renders as full/100% instead of a + * misleading 0% (#3580). + */ +export function glmMonthlyRemainingPercentage(total: number, remaining: number): number { + if (total > 0) { + return Math.max(0, Math.min(100, Math.round((remaining / total) * 100))); + } + return Math.max(0, Math.min(100, Math.round(remaining))); +} + async function getGlmUsage(apiKey: string, providerSpecificData?: Record) { if (!apiKey) { return { message: "API key not available. Add a coding plan API key to view usage." }; @@ -876,8 +893,7 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 0; + const remainingPercentage = glmMonthlyRemainingPercentage(total, remaining); quotas["mcp_monthly"] = { used, @@ -929,7 +945,9 @@ async function getOpenCodeGoUsage(apiKey: string) { if (!res.ok) { if (res.status === 401 || res.status === 403) { - return { message: "OpenCode Go quota endpoint rejected this API key. Chat requests still work." }; + return { + message: "OpenCode Go quota endpoint rejected this API key. Chat requests still work.", + }; } return { message: `OpenCode Go quota API error (${res.status})` }; } @@ -943,7 +961,9 @@ async function getOpenCodeGoUsage(apiKey: string) { const code = toNumber((json as Record).code, 200); if (code === 401 || code === 403 || (json as Record).success === false) { - return { message: "OpenCode Go quota endpoint rejected this API key. Chat requests still work." }; + return { + message: "OpenCode Go quota endpoint rejected this API key. Chat requests still work.", + }; } const data = toRecord((json as Record).data); @@ -1148,7 +1168,9 @@ async function getOpencodeUsage(connectionId: string, apiKey: string) { } try { - const quota = (await fetchOpencodeQuota(connectionId, { apiKey })) as OpencodeTripleWindowQuota | null; + const quota = (await fetchOpencodeQuota(connectionId, { + apiKey, + })) as OpencodeTripleWindowQuota | null; if (!quota) { return { message: "OpenCode connected. Unable to fetch quota data." }; @@ -1477,7 +1499,14 @@ export async function getUsageForProvider( return await getGeminiUsage(accessToken, providerSpecificData, projectId); case "antigravity": case "agy": - return await getAntigravityUsage(accessToken, providerSpecificData, projectId, id, options); + return await getAntigravityUsage( + provider, + accessToken, + providerSpecificData, + projectId, + id, + options + ); case "claude": return await getClaudeUsage(accessToken); case "codex": @@ -1630,10 +1659,7 @@ async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonR const addLimitedQuota = (name: string) => { const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0); if (total <= 0) return null; - const remainingRaw = Math.max( - 0, - toNumber(getFieldValue(remainingQuotas, name, name), 0) - ); + const remainingRaw = Math.max(0, toNumber(getFieldValue(remainingQuotas, name, name), 0)); const remaining = Math.min(remainingRaw, total); const used = Math.max(total - remaining, 0); quotas[name] = { @@ -1905,6 +1931,8 @@ const ANTIGRAVITY_MODELS_CACHE_TTL_MS = 60 * 1000; const ANTIGRAVITY_CREDIT_PROBE_TTL_MS = 5 * 60 * 1000; const _antigravityAvailableModelsCache = new Map(); const _antigravityAvailableModelsInflight = new Map>(); +const _antigravityUserQuotaCache = new Map(); +const _antigravityUserQuotaInflight = new Map>(); const _antigravityCreditProbeCache = new Map(); const _antigravityCreditProbeInflight = new Map>(); @@ -1913,27 +1941,108 @@ const _antigravityCreditProbeInflight = new Map>( // purges stale entries so keys accessed once and never again don't leak memory. // The 2 inflight Maps (availableModelsInflight, creditProbeInflight) self-clean // when the Promise resolves/rejects, so they are NOT touched here. -const _usageCacheCleanupTimer = setInterval(() => { - const now = Date.now(); - for (const [key, entry] of _geminiCliSubCache) { - if (now - entry.fetchedAt > GEMINI_CLI_CACHE_TTL_MS) _geminiCliSubCache.delete(key); - } - for (const [key, entry] of _antigravitySubCache) { - if (now - entry.fetchedAt > ANTIGRAVITY_CACHE_TTL_MS) _antigravitySubCache.delete(key); - } - for (const [key, entry] of _antigravityAvailableModelsCache) { - if (now - entry.fetchedAt > ANTIGRAVITY_MODELS_CACHE_TTL_MS) _antigravityAvailableModelsCache.delete(key); - } - for (const [key, entry] of _antigravityCreditProbeCache) { - if (now - entry.fetchedAt > ANTIGRAVITY_CREDIT_PROBE_TTL_MS) _antigravityCreditProbeCache.delete(key); - } -}, 5 * 60 * 1000); // every 5 minutes +const _usageCacheCleanupTimer = setInterval( + () => { + const now = Date.now(); + for (const [key, entry] of _geminiCliSubCache) { + if (now - entry.fetchedAt > GEMINI_CLI_CACHE_TTL_MS) _geminiCliSubCache.delete(key); + } + for (const [key, entry] of _antigravitySubCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_CACHE_TTL_MS) _antigravitySubCache.delete(key); + } + for (const [key, entry] of _antigravityAvailableModelsCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_MODELS_CACHE_TTL_MS) + _antigravityAvailableModelsCache.delete(key); + } + for (const [key, entry] of _antigravityUserQuotaCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_MODELS_CACHE_TTL_MS) + _antigravityUserQuotaCache.delete(key); + } + for (const [key, entry] of _antigravityCreditProbeCache) { + if (now - entry.fetchedAt > ANTIGRAVITY_CREDIT_PROBE_TTL_MS) + _antigravityCreditProbeCache.delete(key); + } + }, + 5 * 60 * 1000 +); // every 5 minutes _usageCacheCleanupTimer.unref?.(); // Don't prevent process exit interface AntigravityUsageOptions { forceRefresh?: boolean; } +const ANTIGRAVITY_LOCAL_USAGE_WINDOW_MS = 5 * 60 * 60 * 1000; +const ANTIGRAVITY_LOCAL_USAGE_TOKENS_PER_UNIT = 1000; + +// `toClientAntigravityQuotaModelId` was an inline if-ladder here; it is now the single +// source of truth in open-sse/config/antigravityModelAliases.ts (imported above), shared +// with the provider-limits cache sanitizer. (#3821-review LEDGER-5) + +function getAntigravityLocalUsageUnits( + provider: "antigravity" | "agy", + connectionId: string | undefined, + modelId: string, + resetAt: string | null +): number { + if (!connectionId || !modelId || !resetAt) return 0; + + const resetMs = Date.parse(resetAt); + if (!Number.isFinite(resetMs)) return 0; + + const windowStart = new Date(resetMs - ANTIGRAVITY_LOCAL_USAGE_WINDOW_MS).toISOString(); + const windowEnd = new Date(resetMs).toISOString(); + + try { + const db = getDbInstance() as unknown as { + prepare: (sql: string) => { get: (...params: unknown[]) => unknown }; + }; + const row = db + .prepare( + `SELECT COALESCE(SUM( + COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0) + ), 0) AS tokens + FROM usage_history + WHERE provider = ? + AND connection_id = ? + AND model = ? + AND success = 1 + AND timestamp >= ? + AND timestamp < ?` + ) + .get(provider, connectionId, modelId, windowStart, windowEnd) as + | { tokens?: unknown } + | undefined; + + const tokens = Number(row?.tokens || 0); + if (!Number.isFinite(tokens) || tokens <= 0) return 0; + return Math.max(1, Math.ceil(tokens / ANTIGRAVITY_LOCAL_USAGE_TOKENS_PER_UNIT)); + } catch { + return 0; + } +} + +function applyLocalUsageFallback( + quota: UsageQuota, + provider: "antigravity" | "agy", + connectionId: string | undefined, + modelId: string +): UsageQuota { + if (quota.quotaSource !== "fetchAvailableModels" || quota.used > 0 || quota.unlimited) { + return quota; + } + + const localUsed = getAntigravityLocalUsageUnits(provider, connectionId, modelId, quota.resetAt); + if (localUsed <= 0 || quota.total <= 0) return quota; + + const used = Math.min(quota.total, localUsed); + return { + ...quota, + used, + remainingPercentage: Math.max(0, ((quota.total - used) / quota.total) * 100), + quotaSource: "localUsageHistory", + }; +} + function buildAntigravityUsageCacheKey(accessToken: string, projectId?: string | null): string { return `${accessToken.substring(0, 16)}:${projectId || "default"}`; } @@ -2002,6 +2111,57 @@ async function fetchAntigravityAvailableModelsCached( return promise; } +async function fetchAntigravityUserQuotaCached( + accessToken: string, + projectId?: string | null, + options: AntigravityUsageOptions = {} +): Promise { + if (!accessToken || !projectId) return null; + + const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId); + const cached = _antigravityUserQuotaCache.get(cacheKey); + if ( + !options.forceRefresh && + cached && + Date.now() - cached.fetchedAt < ANTIGRAVITY_MODELS_CACHE_TTL_MS + ) { + return cached.data; + } + + const inflight = _antigravityUserQuotaInflight.get(cacheKey); + if (inflight) return inflight; + + const promise = (async () => { + try { + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), + } + ); + + if (!response.ok) return null; + + const data = await response.json(); + _antigravityUserQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); + return data; + } catch { + return null; + } + })().finally(() => { + _antigravityUserQuotaInflight.delete(cacheKey); + }); + + _antigravityUserQuotaInflight.set(cacheKey, promise); + return promise; +} + function extractCodeAssistTierId(subscription: JsonRecord): string { const tierId = extractCodeAssistOnboardTierId(subscription); if (tierId === "legacy-tier") return ""; @@ -2250,12 +2410,14 @@ async function probeAntigravityCreditBalanceUncached( } /** - * Antigravity Usage - Fetch quota from Google Cloud Code API - * Uses fetchAvailableModels API which returns ALL models (including Claude) - * with per-model quotaInfo (remainingFraction, resetTime). - * retrieveUserQuota only returns Gemini models — not suitable for Antigravity. + * Antigravity Usage - Fetch quota from Google Cloud Code API. + * fetchAvailableModels is catalog/eligibility data and may keep reporting full buckets + * after real usage. retrieveUserQuota is the consumption signal for Gemini-family + * buckets, so prefer it when present and fall back to fetchAvailableModels only for + * models that have no retrieveUserQuota entry (for example Claude/GPT OSS buckets). */ async function getAntigravityUsage( + provider: "antigravity" | "agy", accessToken?: string, providerSpecificData?: JsonRecord, connectionProjectId?: string, @@ -2306,33 +2468,52 @@ async function getAntigravityUsage( ); } - const data = await fetchAntigravityAvailableModelsCached(accessToken, projectId, options); + const [data, userQuotaData] = await Promise.all([ + fetchAntigravityAvailableModelsCached(accessToken, projectId, options), + fetchAntigravityUserQuotaCached(accessToken, projectId, options), + ]); const dataObj = toRecord(data); if (dataObj.__antigravityForbidden === true) { return { message: "Antigravity access forbidden. Check subscription." }; } const modelEntries = toRecord(dataObj.models); + const userQuotaEntries = new Map(); + const userQuotaObj = toRecord(userQuotaData); + if (Array.isArray(userQuotaObj.buckets)) { + for (const bucketValue of userQuotaObj.buckets) { + const bucket = toRecord(bucketValue); + const modelId = toClientAntigravityQuotaModelId(String(bucket.modelId || "").trim()); + if (!modelId) continue; + userQuotaEntries.set(modelId, bucket); + } + } const quotas: Record = {}; // Parse per-model quota info from fetchAvailableModels response. - for (const [modelKey, infoValue] of Object.entries(modelEntries)) { + for (const [rawModelKey, infoValue] of Object.entries(modelEntries)) { const info = toRecord(infoValue); const quotaInfo = toRecord(info.quotaInfo); + const modelKey = toClientAntigravityQuotaModelId(rawModelKey); // Skip internal, excluded, and models without quota info if ( + !modelKey || info.isInternal === true || - !isUserCallableAntigravityModelId(modelKey) || + !(provider === "agy" + ? isUserCallableAgyModelId(modelKey) + : isUserCallableAntigravityModelId(modelKey)) || Object.keys(quotaInfo).length === 0 ) { continue; } - const rawFraction = toNumber(quotaInfo.remainingFraction, -1); - const resetAt = parseResetTime(quotaInfo.resetTime); + const liveQuota = userQuotaEntries.get(modelKey); + const quotaSource = liveQuota || quotaInfo; + const rawFraction = toNumber(quotaSource.remainingFraction, -1); + const resetAt = parseResetTime(quotaSource.resetTime); // Distinguish "upstream did not report remainingFraction" from "remaining is 0%". - // A schema drift in Antigravity's quota API (very plausible — internal Google product) - // would otherwise silently mark every model as exhausted across the dashboard. + // fetchAvailableModels is a catalog view and can be stale/full; retrieveUserQuota is + // the source of truth for actual Gemini consumption when it includes the model. const fractionReported = rawFraction >= 0; if (!fractionReported) { console.warn( @@ -2349,13 +2530,50 @@ async function getAntigravityUsage( const remaining = Math.round(total * remainingFraction); const used = isUnlimited ? 0 : Math.max(0, total - remaining); + quotas[modelKey] = applyLocalUsageFallback( + { + used, + total: isUnlimited ? 0 : total, + resetAt, + remainingPercentage: isUnlimited ? 100 : remainingPercentage, + unlimited: isUnlimited, + fractionReported, + quotaSource: liveQuota ? "retrieveUserQuota" : "fetchAvailableModels", + }, + provider, + connectionId, + modelKey + ); + } + + // Include retrieveUserQuota buckets not listed in the static/public Antigravity catalog yet. + // This keeps Provider Limits honest when Google adds a new Gemini tier before our catalog is + // updated. Hidden/internal catalog entries above are still filtered by the public pass. + for (const [modelKey, bucket] of userQuotaEntries) { + if ( + quotas[modelKey] || + !(provider === "agy" + ? isUserCallableAgyModelId(modelKey) + : isUserCallableAntigravityModelId(modelKey)) + ) { + continue; + } + const rawFraction = toNumber(bucket.remainingFraction, -1); + if (rawFraction < 0) continue; + const remainingFraction = Math.max(0, Math.min(1, rawFraction)); + const resetAt = parseResetTime(bucket.resetTime); + const isUnlimited = !resetAt && remainingFraction >= 1; + const QUOTA_NORMALIZED_BASE = 1000; + const total = QUOTA_NORMALIZED_BASE; + const remaining = Math.round(total * remainingFraction); quotas[modelKey] = { - used, + used: isUnlimited ? 0 : Math.max(0, total - remaining), total: isUnlimited ? 0 : total, resetAt, - remainingPercentage: isUnlimited ? 100 : remainingPercentage, + remainingPercentage: isUnlimited ? 100 : remainingFraction * 100, unlimited: isUnlimited, - fractionReported, + fractionReported: true, + quotaSource: "retrieveUserQuota", }; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 3a9dc8a09d..8cf9954602 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -77,7 +77,7 @@ type GeminiRequest = { type CloudCodeEnvelope = { project: string; - model: string; + model?: string; user_prompt_id?: string; userAgent?: "antigravity" | "jetski" | string; requestId?: string; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index ac3e3d00ed..a07fa15ef9 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -11,6 +11,9 @@ import { type GeminiToOpenAIState = { functionIndex: number; + finishReason?: string; + groundingProcessed?: boolean; + hasEmittedContent?: boolean; messageId: string; model: string; pendingThoughtSignature?: string | null; @@ -18,7 +21,20 @@ type GeminiToOpenAIState = { toolCalls: Map; toolNameMap?: Map; textualToolCallBuffer?: string; - hasEmittedContent?: boolean; + textualReasoningTagBuffer?: string; + activeTextualReasoningTag?: string; + textualReasoningContentBuffer?: string; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: { + cached_tokens: number; + }; + completion_tokens_details?: { + reasoning_tokens: number; + }; + }; }; type GeminiFunctionCallPart = { @@ -29,6 +45,162 @@ type GeminiFunctionCallPart = { }; }; +const REASONING_TAG_OPEN_REGEX = + /<(think|thinking|thought|internal_thought)(?=\s|>|\r?\n)(?:\s[^>]*)?(?:>|\r?\n)/i; +const REASONING_TAG_OPEN_PREFIXES = ["") || suffix.includes("\n") || suffix.includes("\r")) return -1; + return REASONING_TAG_OPEN_PREFIXES.some((prefix) => prefix.startsWith(suffix)) ? lastOpen : -1; +} + +function getTrailingReasoningCloseTagPrefixStart(text: string, tagName: string): number { + const lastClose = text.lastIndexOf("") || suffix.includes("\n") || suffix.includes("\r")) return -1; + return ``.startsWith(suffix) ? lastClose : -1; +} + +function consumeTextualReasoningTags( + text: string, + state: GeminiToOpenAIState, + results: Array> +): string { + const pendingTagBuffer = state.textualReasoningTagBuffer || ""; + + if (state.activeTextualReasoningTag && pendingTagBuffer.startsWith("`; + const lowerCombinedClose = combinedClose.toLowerCase(); + const lowerCloseTag = closeTag.toLowerCase(); + + if (lowerCombinedClose.startsWith(lowerCloseTag)) { + emitTextDelta(state.textualReasoningContentBuffer || "", state, results, "reasoning_content"); + state.activeTextualReasoningTag = undefined; + state.textualReasoningContentBuffer = undefined; + state.textualReasoningTagBuffer = undefined; + return combinedClose.slice(closeTag.length); + } + + if (lowerCloseTag.startsWith(lowerCombinedClose)) { + state.textualReasoningTagBuffer = combinedClose; + return ""; + } + } + + let remaining = `${state.textualReasoningTagBuffer || ""}${text}`; + state.textualReasoningTagBuffer = undefined; + + while (remaining) { + if (state.activeTextualReasoningTag) { + const bufferedReasoning = `${state.textualReasoningContentBuffer || ""}${remaining}`; + const closeRegex = new RegExp(``, "i"); + const closeMatch = closeRegex.exec(bufferedReasoning); + if (!closeMatch || closeMatch.index < 0) { + const partialCloseStart = getTrailingReasoningCloseTagPrefixStart( + bufferedReasoning, + state.activeTextualReasoningTag + ); + if (partialCloseStart >= 0) { + state.textualReasoningContentBuffer = bufferedReasoning.slice(0, partialCloseStart); + state.textualReasoningTagBuffer = bufferedReasoning.slice(partialCloseStart); + return ""; + } + state.textualReasoningContentBuffer = bufferedReasoning; + return ""; + } + + emitTextDelta( + bufferedReasoning.slice(0, closeMatch.index), + state, + results, + "reasoning_content" + ); + state.activeTextualReasoningTag = undefined; + state.textualReasoningContentBuffer = undefined; + const closeEnd = bufferedReasoning.indexOf(">", closeMatch.index); + remaining = bufferedReasoning.slice( + closeEnd >= 0 ? closeEnd + 1 : closeMatch.index + closeMatch[0].length + ); + continue; + } + + const openMatch = REASONING_TAG_OPEN_REGEX.exec(remaining); + if (!openMatch || openMatch.index < 0) { + const partialStart = getTrailingReasoningTagPrefixStart(remaining); + if (partialStart >= 0) { + state.textualReasoningTagBuffer = remaining.slice(partialStart); + const prefix = remaining.slice(0, partialStart); + return isIgnorableReasoningTagPrefix(prefix) ? "" : prefix; + } + return remaining; + } + + const before = remaining.slice(0, openMatch.index); + if (before && !isIgnorableReasoningTagPrefix(before)) { + emitTextDelta(before, state, results, "content"); + } + + const tagName = openMatch[1]; + const bodyStart = openMatch.index + openMatch[0].length; + const afterOpen = remaining.slice(bodyStart); + const closeRegex = new RegExp(``, "i"); + const closeMatch = closeRegex.exec(afterOpen); + if (!closeMatch || closeMatch.index < 0) { + state.activeTextualReasoningTag = tagName; + state.textualReasoningContentBuffer = afterOpen; + return ""; + } + + emitTextDelta(afterOpen.slice(0, closeMatch.index), state, results, "reasoning_content"); + remaining = afterOpen.slice(closeMatch.index + closeMatch[0].length); + } + + return ""; +} + +function flushOpenTextualReasoning( + state: GeminiToOpenAIState, + results: Array> +): void { + if (!state.activeTextualReasoningTag && !state.textualReasoningContentBuffer) return; + emitTextDelta(state.textualReasoningContentBuffer || "", state, results, "reasoning_content"); + state.activeTextualReasoningTag = undefined; + state.textualReasoningContentBuffer = undefined; + state.textualReasoningTagBuffer = undefined; +} + +function emitTextDelta( + content: string, + state: GeminiToOpenAIState, + results: Array>, + field: "content" | "reasoning_content" = "content" +) { + if (!content) return; + if (field === "content") state.hasEmittedContent = true; + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { [field]: content }, + finish_reason: null, + }, + ], + }); +} + function normalizeToolCallArgs(args: unknown): unknown { if (typeof args !== "string") return args; const trimmed = args.trim(); @@ -215,6 +387,16 @@ export function geminiToOpenAIResponse(chunk, state) { } if (hasFunctionCall) { + // Flush any still-open textual reasoning wrapper as reasoning_content BEFORE + // the tool call. A signed native functionCall arriving while a `` + // (etc.) tag opened in an earlier chunk is still buffered must not silently + // drop that buffered reasoning — flushOpenTextualReasoning emits it and clears + // the active-tag/content buffers. (LEDGER-4 / #3821-review) + flushOpenTextualReasoning(state, results); + // Also drop any partial open-tag fragment buffered at a chunk boundary + // (flushOpenTextualReasoning early-returns when only this is set), matching the + // pre-fix branch which cleared all three buffers. (#3821-review convergence) + state.textualReasoningTagBuffer = undefined; emitFunctionCallPart(part, state, results); } continue; @@ -226,7 +408,10 @@ export function geminiToOpenAIResponse(chunk, state) { // back to a structured OpenAI tool call so clients/tools do not see it as // assistant prose. if (part.text !== undefined && part.text !== "") { - let accumulated = (state.textualToolCallBuffer || "") + part.text; + const afterReasoning = consumeTextualReasoningTags(part.text, state, results); + if (!afterReasoning) continue; + + let accumulated = (state.textualToolCallBuffer || "") + afterReasoning; let candidate = parseTextualToolCallCandidate(accumulated); @@ -238,10 +423,7 @@ export function geminiToOpenAIResponse(chunk, state) { } if (toolCallIndex < 0) { const lastParen = accumulated.lastIndexOf("("); - if ( - lastParen !== -1 && - "(empty)[Tool call:".startsWith(accumulated.slice(lastParen)) - ) { + if (lastParen !== -1 && "(empty)[Tool call:".startsWith(accumulated.slice(lastParen))) { toolCallIndex = lastParen; } else { const lastBracket = accumulated.lastIndexOf("["); @@ -293,7 +475,7 @@ export function geminiToOpenAIResponse(chunk, state) { } if (state.textualToolCallBuffer) { - const flushedText = state.textualToolCallBuffer + part.text; + const flushedText = state.textualToolCallBuffer + afterReasoning; state.textualToolCallBuffer = ""; state.hasEmittedContent = true; results.push({ @@ -321,7 +503,7 @@ export function geminiToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: { content: part.text }, + delta: { content: afterReasoning }, finish_reason: null, }, ], @@ -444,6 +626,8 @@ export function geminiToOpenAIResponse(chunk, state) { // Finish reason - include usage in final chunk if (candidate.finishReason) { + flushOpenTextualReasoning(state, results); + if (state.textualToolCallBuffer) { const remainingText = state.textualToolCallBuffer; state.textualToolCallBuffer = ""; diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 04cac84fb7..821c9f7538 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -155,6 +155,30 @@ const EMBEDDED_DEFAULTS = { 46, 36, 20, 8, 33, 22, 55, 4, 41, 121, 53, 50, 49, 24, 92, 90, 108, 35, 97, 36, 21, 44, 11, 69, 3, 60, 35, 15, 126, 53, 71, 56, 52, 56, 43, 26, 27, 86, 58, ], + // Claude Code CLI — anthropic oauth client (public, PKCE) + claude_id: [ + 86, 9, 95, 10, 64, 90, 69, 21, 72, 72, 70, 68, 0, 65, 93, 87, 73, 79, 28, 87, 85, 11, 13, 95, + 90, 76, 64, 81, 73, 65, 76, 84, 94, 15, 86, 72, + ], + // Codex CLI — openai oauth client (public, PKCE) + codex_id: [ + 14, 29, 30, 54, 55, 34, 26, 21, 8, 104, 53, 47, 85, 95, 15, 83, 110, 29, 105, 14, 53, 30, 94, + 26, 29, 20, 26, 11, + ], + // Qwen Code CLI — qwen oauth client (public, device flow) + qwen_id: [ + 9, 93, 93, 89, 70, 92, 66, 71, 7, 26, 68, 20, 86, 88, 13, 81, 79, 67, 9, 91, 12, 93, 15, 16, 88, + 69, 23, 4, 20, 21, 64, 84, + ], + // Kimi coding CLI — moonshot oauth client (public) + kimi_id: [ + 94, 90, 11, 92, 20, 89, 66, 69, 72, 73, 65, 76, 86, 65, 93, 7, 75, 20, 28, 86, 90, 94, 95, 95, + 90, 64, 69, 83, 78, 18, 65, 90, 15, 89, 90, 21, + ], + // GitHub Copilot CLI — github oauth app id (public, device flow) + github_copilot_id: [ + 38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87, + ], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/package-lock.json b/package-lock.json index 6469f56293..fa09e1edb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.20", + "version": "3.8.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.20", + "version": "3.8.21", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21587,7 +21587,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.20" + "version": "3.8.21" } } } diff --git a/package.json b/package.json index aec3594573..8f20558fba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.20", + "version": "3.8.21", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -10,20 +10,15 @@ "files": [ "bin/", "dist/", - "src/lib/cli-helper/", "@omniroute/", - "open-sse/mcp-server/index.ts", - "open-sse/mcp-server/server.ts", - "open-sse/mcp-server/httpTransport.ts", - "open-sse/mcp-server/audit.ts", - "open-sse/mcp-server/runtimeHeartbeat.ts", - "open-sse/mcp-server/scopeEnforcement.ts", - "open-sse/mcp-server/schemas/", - "open-sse/mcp-server/tools/", - "open-sse/mcp-server/README.md", - "open-sse/utils/setupPolyfill.ts", - "src/shared/contracts/", - "src/shared/utils/nodeRuntimeSupport.ts", + "open-sse/", + "src/domain/", + "src/lib/", + "src/mitm/", + "src/server/", + "src/shared/", + "src/sse/", + "src/types/", ".env.example", "scripts/build/postinstall.mjs", "bin/cli/runtime/", @@ -36,7 +31,14 @@ "scripts/build/native-binary-compat.mjs", "scripts/build/build-next-isolated.mjs", "README.md", - "LICENSE" + "LICENSE", + "!**/__tests__/**", + "!**/*.test.ts", + "!**/*.test.tsx", + "!**/*.test.js", + "!**/*.test.mjs", + "!**/*.spec.ts", + "!**/*.spec.tsx" ], "workspaces": [ "open-sse" diff --git a/scripts/check/check-docs-symbols.mjs b/scripts/check/check-docs-symbols.mjs index 54079a5164..626ebd53f1 100644 --- a/scripts/check/check-docs-symbols.mjs +++ b/scripts/check/check-docs-symbols.mjs @@ -50,17 +50,14 @@ function isFileRef(p) { // o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse // é o ponto do gate. Issues de tracking devem ser abertas para cada cluster. export const KNOWN_STALE_DOC_REFS = new Set([ - // docs/reference/API_REFERENCE.md — guardrails/shadow entries fixed in separate issues: - "/api/guardrails", // sem dir de API guardrails (feature server-side, sem rota REST) — #3496 - "/api/guardrails/[id]/disable", - "/api/guardrails/[id]/enable", - "/api/guardrails/logs", - "/api/guardrails/test", - "/api/shadow", // sem dir de API shadow (shadow routing não tem rota REST) — #3498 - "/api/shadow/[id]", - "/api/shadow/[id]/results", - "/api/shadow/metrics", - // docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada: — #3498 + // docs/reference/API_REFERENCE.md — guardrails/shadow doc-fiction RESOLVED in #3496: + // GET /api/guardrails + POST /api/guardrails/test are now REAL routes (wrapping the + // existing guardrailRegistry); the fictional enable/disable/logs rows and the entire + // shadow table were removed from the doc (shadow A-B comparison is combo-config + + // /api/combos/metrics). No allowlist entries needed for these anymore. + // docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada + // (Phase 2). Refs INTENCIONAIS: o doc agora traz um banner "⚠️ Not yet implemented + // — Phase 2" acima da tabela de endpoints. Mantidos aqui até a feature existir. — #3498 "/api/discovery/results", "/api/discovery/results/:id", "/api/discovery/scan", diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index 7db3051bf5..6c504376ac 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -24,11 +24,7 @@ const IGNORE = [ // inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a // chamada morta. NÃO adicione novos aqui sem justificativa — esse é o ponto do gate. const KNOWN_MISSING = new Set([ - "/api/gamification/level", // profile/page.tsx — rota inexistente (gamification tem transfer/leaderboard/… mas não level) - "/api/gamification/badges", // profile/page.tsx — idem - "/api/gamification/badges/earned", // profile/page.tsx — idem "/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian - "/api/tools/agent-bridge/upstream-ca/test", // UpstreamCaField.tsx — rota inexistente ]); function walk(dir, acc = []) { diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index f5407d19a4..6432405012 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -52,25 +52,12 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // arquivos, então congelar por valor cobre ambas as cópias). Para congelar um valor // só num arquivo:linha específico, use a chave "arquivo:linha:valor". // -// Tracking: estes 5 valores (9 call-sites) devem virar uma issue de segurança e -// migrar para resolvePublicCred() — Gemini/Antigravity já seguem o padrão correto. -export const KNOWN_LITERAL_CREDS = new Set([ - // Claude — CLAUDE_OAUTH_CLIENT_ID (public, PKCE auth-code flow) - // providerRegistry.ts:659 + oauth.ts:37 - "9d1c250a-e61b-44d9-88ed-5944d1962f5e", - // Codex (OpenAI) — CODEX_OAUTH_CLIENT_ID (public, PKCE) - // providerRegistry.ts:831 + oauth.ts:54 - "app_EMoamEEZ73f0CkXaXp7hrann", - // Qwen — QWEN_OAUTH_CLIENT_ID (public, device-code + PKCE) - // providerRegistry.ts:925 + oauth.ts:101 - "f0304373b74a44d2b584a3fb70ca9e56", - // Kimi Coding — KIMI_CODING_OAUTH_CLIENT_ID (public, device-code) - // providerRegistry.ts:1961 + oauth.ts:136 - "17e5f671-d194-4dfb-9706-5516cb48c098", - // GitHub Copilot — GITHUB_OAUTH_CLIENT_ID (public, device-code) - // oauth.ts:238 - "Iv1.b507a08c87ecfe98", -]); +// All five public client_ids (9 call-sites) were migrated to resolvePublicCred() in +// #3493 (embedded as claude_id/codex_id/qwen_id/kimi_id/github_copilot_id in +// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern. The +// allowlist is now empty — any new literal public client_id must be embedded via +// resolvePublicCred(), not frozen here. +export const KNOWN_LITERAL_CREDS = new Set([]); /** * Encontra atribuições de uma chave de credencial a uma string literal não-vazia. diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 7653c1a16f..968c189fe9 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -124,6 +124,21 @@ function getSoonestResetMs(quotas: any[] | undefined): number { return soonest; } +function shouldAutoRefreshQuota(provider: string, cached: any): boolean { + const quotas = cached?.quotas; + if (!Array.isArray(quotas) || quotas.length === 0) return true; + if (provider !== "antigravity" && provider !== "agy") return false; + + return quotas.some( + (q: any) => + q && + typeof q.modelKey === "string" && + q.modelKey.startsWith("gemini-") && + !q.isCredits && + q.quotaSource !== "retrieveUserQuota" + ); +} + const getQuotaBarWidthClass = (pct: number) => { if (pct <= 10) return "w-[10%]"; if (pct <= 20) return "w-1/5"; @@ -670,8 +685,7 @@ export default function ProviderLimits({ autoLiveFetchedRef.current = true; for (const conn of visibleConnections) { const cached = quotaData[conn.id]; - const hasQuota = Array.isArray(cached?.quotas) && cached.quotas.length > 0; - if (!hasQuota) { + if (shouldAutoRefreshQuota(conn.provider, cached)) { void fetchQuota(conn.id, conn.provider, { force: true }).catch(() => {}); } } @@ -779,7 +793,9 @@ export default function ProviderLimits({ onClick={refreshAll} disabled={refreshingAll} className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" - title={autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")} + title={ + autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll") + } > 0 ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( - Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)) + Math.max( + 0, + autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current) + ) )}` : t("refreshAll")} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 159c8bd46e..3e02c550cc 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -290,6 +290,10 @@ export function parseQuotaData(provider, data) { normalizedQuotas.push( normalizeQuotaEntry(modelKey, quota, { modelKey: modelKey, + ...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}), + ...(quota?.fractionReported !== undefined + ? { fractionReported: quota.fractionReported } + : {}), }) ); }); diff --git a/src/app/api/gamification/badges/earned/route.ts b/src/app/api/gamification/badges/earned/route.ts new file mode 100644 index 0000000000..65a0944a9d --- /dev/null +++ b/src/app/api/gamification/badges/earned/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/gamification/badges/earned — badges earned by a key, or the operator-wide + * earned set (distinct across all keys) when no `apiKeyId` is supplied. (#3484) + * + * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. + */ +import { NextRequest, NextResponse } from "next/server"; + +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getBadges, getAllEarnedBadges } from "@/lib/db/gamification"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const apiKeyId = new URL(request.url).searchParams.get("apiKeyId"); + const badges = apiKeyId ? getBadges(apiKeyId) : getAllEarnedBadges(); + return NextResponse.json({ badges }, { headers: CORS_HEADERS }); +} diff --git a/src/app/api/gamification/badges/route.ts b/src/app/api/gamification/badges/route.ts new file mode 100644 index 0000000000..6eeccf062e --- /dev/null +++ b/src/app/api/gamification/badges/route.ts @@ -0,0 +1,28 @@ +/** + * GET /api/gamification/badges — the full built-in badge catalog (definitions). + * Seeds the built-in badges first (idempotent INSERT OR IGNORE) so the profile + * grid is populated even on installs where seeding never ran. (#3484, see #3472) + * + * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. + */ +import { NextRequest, NextResponse } from "next/server"; + +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getBadgeDefinitions } from "@/lib/db/gamification"; +import { seedBuiltinBadges } from "@/lib/gamification/badges"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + await seedBuiltinBadges(); + + const category = new URL(request.url).searchParams.get("category") || undefined; + const badges = getBadgeDefinitions(category); + return NextResponse.json({ badges }, { headers: CORS_HEADERS }); +} diff --git a/src/app/api/gamification/level/route.ts b/src/app/api/gamification/level/route.ts new file mode 100644 index 0000000000..572081967c --- /dev/null +++ b/src/app/api/gamification/level/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/gamification/level — current XP/level for a key, or the operator-wide + * aggregate when no `apiKeyId` is supplied (the dashboard profile page case). (#3484) + * + * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. + */ +import { NextRequest, NextResponse } from "next/server"; + +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getXp, getAggregateXp } from "@/lib/db/gamification"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const apiKeyId = new URL(request.url).searchParams.get("apiKeyId"); + const level = apiKeyId ? getXp(apiKeyId) : getAggregateXp(); + return NextResponse.json({ level }, { headers: CORS_HEADERS }); +} diff --git a/src/app/api/guardrails/route.ts b/src/app/api/guardrails/route.ts new file mode 100644 index 0000000000..de4b811e79 --- /dev/null +++ b/src/app/api/guardrails/route.ts @@ -0,0 +1,32 @@ +/** + * GET /api/guardrails — list the registered runtime guardrails and their status + * (name / enabled / priority). Guardrails run on every request; per-call opt-out + * is done via the `x-omniroute-disabled-guardrails` header, so there is no + * persisted enable/disable surface — see POST /api/guardrails/test to dry-run + * the pipeline. (#3496) + * + * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. + */ +import { NextRequest, NextResponse } from "next/server"; + +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { registerDefaultGuardrails } from "@/lib/guardrails/registry"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const registry = registerDefaultGuardrails(); + const guardrails = registry.list().map((guardrail) => ({ + name: guardrail.name, + enabled: guardrail.enabled, + priority: guardrail.priority, + })); + + return NextResponse.json({ guardrails }, { headers: CORS_HEADERS }); +} diff --git a/src/app/api/guardrails/test/route.ts b/src/app/api/guardrails/test/route.ts new file mode 100644 index 0000000000..29f2c4872b --- /dev/null +++ b/src/app/api/guardrails/test/route.ts @@ -0,0 +1,51 @@ +/** + * POST /api/guardrails/test — dry-run the pre-call guardrail pipeline over a + * sample input and return the per-guardrail verdict (blocked / modified / + * skipped / passed) plus the resulting (possibly masked) payload. Lets operators + * preview PII masking, prompt-injection and vision-bridge behavior without + * issuing a real upstream request. (#3496) + * + * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. + */ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { registerDefaultGuardrails } from "@/lib/guardrails/registry"; + +const TestRequestSchema = z.object({ + input: z.union([z.string(), z.record(z.unknown()), z.array(z.unknown())]), + disabledGuardrails: z.array(z.string()).optional(), +}); + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let parsed: z.infer; + try { + parsed = TestRequestSchema.parse(await request.json()); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid request body — expected { input: string | object | array, disabledGuardrails?: string[] }", + type: "invalid_request", + }); + } + + const registry = registerDefaultGuardrails(); + const outcome = await registry.runPreCallHooks(parsed.input, { + disabledGuardrails: parsed.disabledGuardrails, + }); + + return NextResponse.json( + { blocked: outcome.blocked, results: outcome.results, payload: outcome.payload }, + { headers: CORS_HEADERS } + ); +} diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 49c701a9bc..b6a8ab8d1b 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -7,6 +7,23 @@ import { kiroImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +/** + * Build the user-facing error message for a failed Kiro/Amazon-Q token import. + * The catch previously returned a bare `Internal server error`, which hid the + * real cause — the failure happens while validating/refreshing the imported + * refresh token against AWS (e.g. `invalid_grant`, an expired token, or a region + * mismatch) — so the dashboard only ever showed a generic 500 (#3589). The cause + * is now surfaced through `sanitizeErrorMessage()` (Rule #12 — no stack, no + * secrets), falling back to the generic message only when there is nothing to + * report. The `{ error: }` shape is unchanged, so the import UI keeps + * rendering it the same way. + */ +export function buildKiroImportError(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error ?? ""); + return sanitizeErrorMessage(raw) || "Internal server error"; +} async function requireOAuthImportAuth(request: Request) { if (!(await isAuthRequired(request))) return null; @@ -100,7 +117,7 @@ export async function POST(request: Request) { }); } catch (error: any) { console.error("Kiro-compatible import token error:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + return NextResponse.json({ error: buildKiroImportError(error) }, { status: 500 }); } } diff --git a/src/app/api/tools/agent-bridge/upstream-ca/test/route.ts b/src/app/api/tools/agent-bridge/upstream-ca/test/route.ts new file mode 100644 index 0000000000..0ca12d9b33 --- /dev/null +++ b/src/app/api/tools/agent-bridge/upstream-ca/test/route.ts @@ -0,0 +1,63 @@ +/** + * POST /api/tools/agent-bridge/upstream-ca/test + * + * Validate-only (dry-run) counterpart of POST /api/tools/agent-bridge/upstream-ca: + * checks the upstream CA file exists and is a parseable PEM certificate, WITHOUT + * persisting the path or activating it via configureUpstreamCa(). Backs the + * UpstreamCaField "Test" button, which previously 404'd (#3488). + * + * LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix in routeGuard.ts. + */ +import crypto from "crypto"; +import fs from "fs"; + +import { AgentBridgeUpstreamCaPostSchema } from "@/shared/schemas/agentBridge"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { createErrorResponse } from "@/lib/api/errorResponse"; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = AgentBridgeUpstreamCaPostSchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ + status: 400, + message: "Invalid request body", + details: parsed.error.flatten(), + }); + } + + const { path: caPath } = parsed.data; + + if (!fs.existsSync(caPath)) { + return createErrorResponse({ status: 400, message: `Upstream CA file not found: ${caPath}` }); + } + + let pem: string; + try { + pem = fs.readFileSync(caPath, "utf8"); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 400, message: `Unable to read upstream CA file: ${msg}` }); + } + + if (!pem.includes("-----BEGIN CERTIFICATE-----")) { + return createErrorResponse({ + status: 400, + message: "File is not a PEM certificate (missing a -----BEGIN CERTIFICATE----- block).", + }); + } + + try { + const cert = new crypto.X509Certificate(pem); + return Response.json({ ok: true, path: caPath, subject: cert.subject, validTo: cert.validTo }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 400, message: `Invalid certificate: ${msg}` }); + } +} diff --git a/src/app/api/usage/provider-limits/route.ts b/src/app/api/usage/provider-limits/route.ts index 2c1fdd6541..03403ca297 100644 --- a/src/app/api/usage/provider-limits/route.ts +++ b/src/app/api/usage/provider-limits/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { - getCachedProviderLimitsMap, getLastProviderLimitsAutoSyncTime, getProviderLimitsSyncIntervalMinutes, + getSanitizedCachedProviderLimitsMap, syncAllProviderLimits, } from "@/lib/usage/providerLimits"; @@ -13,7 +13,7 @@ import { export async function GET() { try { return NextResponse.json({ - caches: getCachedProviderLimitsMap(), + caches: await getSanitizedCachedProviderLimitsMap(), intervalMinutes: getProviderLimitsSyncIntervalMinutes(), lastAutoSyncAt: await getLastProviderLimitsAutoSyncTime(), }); @@ -30,7 +30,7 @@ export async function GET() { export async function POST() { try { const result = await syncAllProviderLimits({ source: "manual" }); - const caches = getCachedProviderLimitsMap(); + const caches = await getSanitizedCachedProviderLimitsMap(); return NextResponse.json({ ...result, caches, diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index a146e46c1b..470b78e8cf 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -2,6 +2,7 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { asTextCompletionResponse } from "./textCompletionTransform.ts"; let initPromise = null; const injectionGuard = createInjectionGuard(); @@ -74,13 +75,18 @@ export async function POST(request: Request) { headers: request.headers, body: JSON.stringify(normalized), }); - return await handleChat(newRequest, buildClientRawRequest(request, body)); + // #3571 — translate the chat-pipeline response back to the legacy + // text-completion shape so OpenAI Completion clients (e.g. TabbyML) work. + return await asTextCompletionResponse( + await handleChat(newRequest, buildClientRawRequest(request, body)) + ); } } } catch (error) { console.error("[SECURITY] Prompt injection guard failed:", error); } - // Standard path: body already has messages[] (chat format) - return await handleChat(request); + // Standard path: body already has messages[] (chat format). Still emit the legacy + // text-completion shape — this is the /v1/completions contract (#3571). + return await asTextCompletionResponse(await handleChat(request)); } diff --git a/src/app/api/v1/completions/textCompletionTransform.ts b/src/app/api/v1/completions/textCompletionTransform.ts new file mode 100644 index 0000000000..e492734243 --- /dev/null +++ b/src/app/api/v1/completions/textCompletionTransform.ts @@ -0,0 +1,127 @@ +/** + * #3571 — `/v1/completions` is the legacy OpenAI Completions API. OmniRoute routes + * it internally through the chat pipeline, which emits chat-shaped payloads + * (`chat.completion` / `chat.completion.chunk` with `choices[].message|delta.content`). + * Legacy Completion clients (e.g. TabbyML's `openai/completion` backend) require + * `choices[].text` and `object: "text_completion"`, and crash on the chat shape + * (`Error("missing field 'text'")`). + * + * These helpers translate a chat-shaped object/stream back to the legacy + * text-completion shape so the endpoint honours its OpenAI Completions contract. + */ + +type AnyRec = Record; + +function choiceText(choice: AnyRec): string { + if (!choice || typeof choice !== "object") return ""; + // chat.completion → message.content; chat.completion.chunk → delta.content; + // already-text shape → text. + const text = choice.message?.content ?? choice.delta?.content ?? choice.text ?? ""; + return typeof text === "string" ? text : ""; +} + +/** + * Convert a single chat.completion / chat.completion.chunk object into the legacy + * text-completion shape (`object: "text_completion"`, `choices[].text`). + * Non-object input and already-text_completion objects are returned unchanged. + */ +export function toTextCompletionObject(obj: AnyRec): AnyRec { + if (!obj || typeof obj !== "object" || !Array.isArray(obj.choices)) return obj; + + const choices = obj.choices.map((c: AnyRec, i: number) => ({ + text: choiceText(c), + index: typeof c?.index === "number" ? c.index : i, + logprobs: c?.logprobs ?? null, + finish_reason: c?.finish_reason ?? null, + })); + + const out: AnyRec = { + id: obj.id, + object: "text_completion", + created: obj.created, + model: obj.model, + choices, + }; + if (obj.usage) out.usage = obj.usage; + if (obj.system_fingerprint !== undefined) out.system_fingerprint = obj.system_fingerprint; + return out; +} + +/** + * Transform the payload of a single SSE `data:` line. Returns the re-serialized + * text-completion JSON, `"[DONE]"` unchanged, or the original payload when it is + * not JSON we recognise. + */ +export function transformSseData(payload: string): string { + const trimmed = payload.trim(); + if (trimmed === "" || trimmed === "[DONE]") return trimmed; + try { + return JSON.stringify(toTextCompletionObject(JSON.parse(trimmed))); + } catch { + return trimmed; + } +} + +function transformLine(line: string): string { + if (!line.startsWith("data:")) return line; + const payload = line.slice("data:".length).replace(/^ /, ""); + return "data: " + transformSseData(payload); +} + +/** + * A line-oriented SSE TransformStream that rewrites each `data:` event from chat + * shape to text-completion shape, passing through blank separators and `[DONE]`. + */ +export function createTextCompletionStreamTransformer(): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + return new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + controller.enqueue(encoder.encode(transformLine(line) + "\n")); + } + }, + flush(controller) { + if (buffer.length > 0) controller.enqueue(encoder.encode(transformLine(buffer))); + }, + }); +} + +/** + * Wrap a chat-pipeline Response so `/v1/completions` returns the legacy + * text-completion shape. Error responses and non-JSON/non-SSE bodies pass through. + */ +export async function asTextCompletionResponse(res: Response): Promise { + if (!res.ok) return res; + const contentType = res.headers.get("content-type") || ""; + + if (contentType.includes("text/event-stream") && res.body) { + // Re-serialization changes the byte length, so drop any upstream content-length + // (a buffered SSE body could otherwise advertise a stale length and truncate/hang + // the client), mirroring the JSON branch below. (#3821-review LEDGER-8) + const headers = new Headers(res.headers); + headers.delete("content-length"); + return new Response(res.body.pipeThrough(createTextCompletionStreamTransformer()), { + status: res.status, + headers, + }); + } + + if (contentType.includes("application/json")) { + const obj = await res.json().catch(() => null); + if (obj === null) return res; + const headers = new Headers(res.headers); + headers.delete("content-length"); // body length changed after re-serialization + return new Response(JSON.stringify(toTextCompletionObject(obj)), { + status: res.status, + headers, + }); + } + + return res; +} diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts index 84522e939c..7bfb5e4e37 100644 --- a/src/lib/db/gamification.ts +++ b/src/lib/db/gamification.ts @@ -286,6 +286,62 @@ export function getBadgeDefinitions(category?: string): BadgeDefinition[] { })); } +/** + * Aggregate XP across every API key (the operator-wide profile view used by the + * dashboard profile page, which is not scoped to a single key). Sums total XP and + * takes the highest reached level. (#3484) + */ +export function getAggregateXp(): UserLevelRow { + const row = db() + .prepare( + `SELECT COALESCE(SUM(total_xp), 0) AS total_xp, + COALESCE(MAX(current_level), 1) AS current_level, + MAX(updated_at) AS updated_at + FROM user_levels` + ) + .get() as { total_xp: number; current_level: number; updated_at: string | null }; + return { + apiKeyId: "*", + totalXp: row?.total_xp ?? 0, + currentLevel: row?.current_level ?? 1, + updatedAt: row?.updated_at ?? "", + }; +} + +/** + * Distinct badges earned by any API key (operator-wide earned set for the profile + * page). Deduplicated by badge id, keeping the earliest unlock. (#3484) + */ +export function getAllEarnedBadges(): UserBadge[] { + const rows = db() + .prepare( + `SELECT ub.badge_id, MIN(ub.unlocked_at) AS unlocked_at, + bd.name, bd.description, bd.icon, bd.category, bd.rarity + FROM user_badges ub + JOIN badge_definitions bd ON bd.id = ub.badge_id + GROUP BY ub.badge_id` + ) + .all() as Array<{ + badge_id: string; + unlocked_at: string; + name: string; + description: string | null; + icon: string | null; + category: string | null; + rarity: string; + }>; + return rows.map((r) => ({ + apiKeyId: "*", + badgeId: r.badge_id, + unlockedAt: r.unlocked_at, + badgeName: r.name, + badgeDescription: r.description, + badgeIcon: r.icon, + badgeCategory: r.category, + badgeRarity: r.rarity, + })); +} + // ──────────────── Token Ledger ──────────────── export function transferTokens( diff --git a/src/lib/oauth/codexDeviceFlow.ts b/src/lib/oauth/codexDeviceFlow.ts index 2e6508ef88..2ad9583570 100644 --- a/src/lib/oauth/codexDeviceFlow.ts +++ b/src/lib/oauth/codexDeviceFlow.ts @@ -24,8 +24,11 @@ * * NOTE: this module must stay free of server-only imports (e.g. CODEX_CONFIG, * open-sse) so it can be bundled for the browser. The client_id below is the - * public Codex CLI client identifier (same literal as CODEX_CONFIG.clientId); - * it relies on PKCE, not secrecy (RFC 8252). + * public Codex CLI client identifier (same value as CODEX_CONFIG.clientId); + * it relies on PKCE, not secrecy (RFC 8252). The server-side copies were moved to + * resolvePublicCred() in #3493 (Rule #11); this browser-bundled copy stays a literal + * by necessity — it cannot import open-sse's publicCreds without pulling server code + * into the browser bundle. */ const BASE_URL = "https://auth.openai.com"; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 586bc4abb4..c372f385df 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -34,7 +34,7 @@ import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitla // Claude OAuth Configuration (Authorization Code Flow with PKCE) export const CLAUDE_CONFIG = { - clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + clientId: resolvePublicCred("claude_id", "CLAUDE_OAUTH_CLIENT_ID"), authorizeUrl: "https://claude.ai/oauth/authorize", tokenUrl: "https://api.anthropic.com/v1/oauth/token", redirectUri: @@ -51,7 +51,7 @@ export const CLAUDE_CONFIG = { // Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE) export const CODEX_CONFIG = { - clientId: process.env.CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann", + clientId: resolvePublicCred("codex_id", "CODEX_OAUTH_CLIENT_ID"), authorizeUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", scope: "openid profile email offline_access", @@ -98,7 +98,7 @@ export const GEMINI_CONFIG = { // Qwen OAuth Configuration (Device Code Flow with PKCE) export const QWEN_CONFIG = { - clientId: process.env.QWEN_OAUTH_CLIENT_ID || "f0304373b74a44d2b584a3fb70ca9e56", + clientId: resolvePublicCred("qwen_id", "QWEN_OAUTH_CLIENT_ID"), deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", scope: "openid profile email model.completion", @@ -133,7 +133,7 @@ export const QODER_CONFIG = { // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { - clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098", + clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", tokenUrl: "https://auth.kimi.com/api/oauth/token", }; @@ -235,7 +235,7 @@ export const OPENAI_CONFIG = { // GitHub Copilot OAuth Configuration (Device Code Flow) export const GITHUB_CONFIG = { - clientId: process.env.GITHUB_OAUTH_CLIENT_ID || "Iv1.b507a08c87ecfe98", + clientId: resolvePublicCred("github_copilot_id", "GITHUB_OAUTH_CLIENT_ID"), deviceCodeUrl: "https://github.com/login/device/code", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index bf6edd7df5..2ae6da88d2 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -18,12 +18,21 @@ import { getMachineId } from "@/shared/utils/machine"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; -import { rotationGroupFor, serializeRefresh } from "@omniroute/open-sse/services/refreshSerializer.ts"; +import { + rotationGroupFor, + serializeRefresh, +} from "@omniroute/open-sse/services/refreshSerializer.ts"; import { extractCodeAssistOnboardTierId, extractCodeAssistSubscriptionTier, } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { + isUserCallableAntigravityModelId, + toClientAntigravityModelId, +} from "@omniroute/open-sse/config/antigravityModelAliases.ts"; +import { isUserCallableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts"; +import { onUsageRecorded } from "./usageEvents"; type JsonRecord = Record; @@ -64,6 +73,8 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; +const DEFAULT_PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS = 5_000; +const pendingPostUsageRefreshes = new Set(); function isRecord(value: unknown): value is JsonRecord { return value !== null && typeof value === "object" && !Array.isArray(value); @@ -83,6 +94,143 @@ function toProviderLimitsCacheEntry( }; } +function getProviderLimitsPostUsageRefreshDelayMs(): number { + const raw = Number(process.env.PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS ?? ""); + return Number.isFinite(raw) && raw >= 0 + ? raw + : DEFAULT_PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS; +} + +function scheduleProviderLimitsPostUsageRefresh(connectionId: string): void { + if (!connectionId || pendingPostUsageRefreshes.has(connectionId)) return; + + pendingPostUsageRefreshes.add(connectionId); + const timer = setTimeout(() => { + pendingPostUsageRefreshes.delete(connectionId); + void fetchAndPersistProviderLimits(connectionId, "scheduled", { + allowRotatingRefresh: true, + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[ProviderLimits] Post-usage refresh failed for connection ${connectionId}: ${message}` + ); + }); + }, getProviderLimitsPostUsageRefreshDelayMs()); + timer.unref?.(); +} + +export function notifyProviderUsageRecorded( + provider: string | null | undefined, + connectionId: string | null | undefined +): void { + if ((provider !== "antigravity" && provider !== "agy") || !connectionId) return; + scheduleProviderLimitsPostUsageRefresh(connectionId); +} + +// Subscribe at module load so usageHistory can emit usage events without importing +// this module (and its executors/translator import graph). This module is loaded by +// the provider-limits route and the background auto-sync scheduler at server boot. +onUsageRecorded(notifyProviderUsageRecorded); + +function isUsageQuotaKeyAllowed(provider: string, quotaKey: string): boolean { + if (quotaKey === "credits" || quotaKey === "models") return true; + if (provider === "antigravity") return isUserCallableAntigravityModelId(quotaKey); + if (provider === "agy") return isUserCallableAgyModelId(quotaKey); + return true; +} + +function normalizeUsageQuotaKey(provider: string, quotaKey: string): string | null { + if (quotaKey === "credits" || quotaKey === "models") return quotaKey; + if (provider === "antigravity" || provider === "agy") { + const clientKey = toClientAntigravityModelId(quotaKey); + return isUsageQuotaKeyAllowed(provider, clientKey) ? clientKey : null; + } + return isUsageQuotaKeyAllowed(provider, quotaKey) ? quotaKey : null; +} + +function normalizeUsageQuotasForProvider( + provider: string, + quotas: JsonRecord | null | undefined +): JsonRecord | null { + if (!isRecord(quotas)) return quotas ?? null; + + const normalized: JsonRecord = {}; + let changed = false; + + for (const [quotaKey, quota] of Object.entries(quotas)) { + const normalizedKey = normalizeUsageQuotaKey(provider, quotaKey); + if (!normalizedKey) { + changed = true; + continue; + } + + const existing = normalized[normalizedKey]; + if (existing && isRecord(existing) && isRecord(quota)) { + const existingSource = String(existing.quotaSource ?? ""); + const nextSource = String(quota.quotaSource ?? ""); + const sourceRank: Record = { + fetchAvailableModels: 0, + localUsageHistory: 1, + retrieveUserQuota: 2, + }; + if ((sourceRank[existingSource] ?? 0) > (sourceRank[nextSource] ?? 0)) { + continue; + } + } + + normalized[normalizedKey] = quota as JsonRecord; + if (normalizedKey !== quotaKey) changed = true; + } + + return changed ? normalized : quotas; +} + +function sanitizeUsageQuotasForProvider(provider: string, usage: JsonRecord): JsonRecord { + if (provider !== "antigravity" && provider !== "agy") return usage; + if (!isRecord(usage.quotas)) return usage; + + const sanitizedQuotas = normalizeUsageQuotasForProvider(provider, usage.quotas); + return sanitizedQuotas === usage.quotas ? usage : { ...usage, quotas: sanitizedQuotas }; +} + +function hasRetrieveUserQuotaSource( + provider: string, + cache: ProviderLimitsCacheEntry | undefined +): boolean { + if (provider !== "antigravity" && provider !== "agy") return true; + if (!cache?.quotas) return false; + return Object.values(cache.quotas).some((quota) => { + if (!isRecord(quota)) return false; + return quota.quotaSource === "retrieveUserQuota"; + }); +} + +function sanitizeProviderLimitsCacheForConnection( + connection: ProviderConnectionLike | null | undefined, + entry: ProviderLimitsCacheEntry | null +): ProviderLimitsCacheEntry | null { + if (!connection || !entry || !entry.quotas) return entry; + if (connection.provider !== "antigravity" && connection.provider !== "agy") return entry; + + const sanitizedQuotas = normalizeUsageQuotasForProvider(connection.provider, entry.quotas); + return sanitizedQuotas === entry.quotas ? entry : { ...entry, quotas: sanitizedQuotas }; +} + +function shouldRefreshProviderLimitsCache( + connection: ProviderConnectionLike, + cache: ProviderLimitsCacheEntry | undefined +): boolean { + if (!cache?.quotas) return true; + if (connection.provider !== "antigravity" && connection.provider !== "agy") return false; + + return ( + !hasRetrieveUserQuotaSource(connection.provider, cache) || + Object.keys(cache.quotas).some( + (quotaKey) => !isUsageQuotaKeyAllowed(connection.provider, quotaKey) + ) + ); +} + function isSupportedUsageConnection(connection: ProviderConnectionLike | null): boolean { if ( !connection || @@ -162,9 +310,18 @@ export async function refreshAndUpdateCredentials( // Serialize the actual token mint per rotation group so two sibling accounts // never hit Auth0 concurrently (passthrough for non-rotating providers). - const refreshResult = await serializeRefresh(connection.provider, () => + const refreshResult = (await serializeRefresh(connection.provider, () => executor.refreshCredentials(credentials, console) - ); + )) as + | (JsonRecord & { + accessToken?: string; + refreshToken?: string; + expiresIn?: number; + expiresAt?: string; + copilotToken?: string; + copilotTokenExpiresAt?: string; + }) + | null; if (!refreshResult) { if (connection.provider === "github" && connection.accessToken) { @@ -443,6 +600,46 @@ export function getCachedProviderLimitsMap(): Record +> { + const caches = getAllProviderLimitsCache(); + // Sanitization only rewrites Antigravity/agy quota keys; every other provider's cache + // entry is returned untouched (see sanitizeProviderLimitsCacheForConnection). The + // dashboard polls this on an auto-refresh interval, so avoid the unconditional + // `SELECT * FROM provider_connections` + per-row credential decryption that the + // previous implementation paid on every poll: skip the scan entirely when nothing is + // cached, and otherwise fetch ONLY the Antigravity/agy connections. For any other + // provider, byId.get(id) is undefined and the entry is returned verbatim — identical + // output to scanning every active connection, but without decrypting unrelated keys. + // (LEDGER-2 / #3821-review) + const connectionIds = Object.keys(caches); + if (connectionIds.length === 0) return {}; + + const sanitizableConnections = [ + ...((await getProviderConnections({ + isActive: true, + provider: "antigravity", + })) as unknown as ProviderConnectionLike[]), + ...((await getProviderConnections({ + isActive: true, + provider: "agy", + })) as unknown as ProviderConnectionLike[]), + ]; + if (sanitizableConnections.length === 0) { + // No connection can change the cache → return the raw entries unchanged. + return { ...caches }; + } + + const byId = new Map(sanitizableConnections.map((conn) => [conn.id, conn])); + const sanitized: Record = {}; + for (const [connectionId, entry] of Object.entries(caches)) { + sanitized[connectionId] = + sanitizeProviderLimitsCacheForConnection(byId.get(connectionId), entry) || entry; + } + return sanitized; +} + export async function fetchLiveProviderLimits(connectionId: string): Promise<{ connection: ProviderConnectionLike; usage: JsonRecord; @@ -469,7 +666,10 @@ async function fetchLiveProviderLimitsWithOptions( } if (connection.authType !== "oauth") { - const usage = (await getUsageForProvider(connection, options)) as JsonRecord; + const usage = sanitizeUsageQuotasForProvider( + connection.provider, + (await getUsageForProvider(connection as unknown as JsonRecord, options)) as JsonRecord + ); if (isRecord(usage.quotas)) { setQuotaCache(connectionId, connection.provider, usage.quotas); } @@ -497,7 +697,10 @@ async function fetchLiveProviderLimitsWithOptions( await syncToCloudIfEnabled(); } - let usageData = (await getUsageForProvider(conn, options)) as JsonRecord; + let usageData = sanitizeUsageQuotasForProvider( + conn.provider, + (await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord + ); // Reactive 401 recovery (on-demand/force path only): an unauthorized usage // response means the access token is actually dead. Force ONE serialized @@ -512,7 +715,10 @@ async function fetchLiveProviderLimitsWithOptions( if (forced.refreshed) { conn = forced.connection; await syncToCloudIfEnabled(); - usageData = (await getUsageForProvider(conn, options)) as JsonRecord; + usageData = sanitizeUsageQuotasForProvider( + conn.provider, + (await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord + ); } } @@ -679,8 +885,12 @@ export async function syncAllProviderLimits( }; const fetchOne = async (connection: ProviderConnectionLike) => { + const existingCache = getProviderLimitsCache(connection.id); + const forceRefresh = + source === "manual" || + shouldRefreshProviderLimitsCache(connection, existingCache || undefined); const { usage } = await fetchLiveProviderLimitsWithOptions(connection.id, { - forceRefresh: source === "manual", + forceRefresh, }); const cache = toProviderLimitsCacheEntry(usage, source); return { connectionId: connection.id, cache }; diff --git a/src/lib/usage/usageEvents.ts b/src/lib/usage/usageEvents.ts new file mode 100644 index 0000000000..dbd2a507eb --- /dev/null +++ b/src/lib/usage/usageEvents.ts @@ -0,0 +1,40 @@ +/** + * Lightweight usage-event bus. + * + * Decouples usage recording (usageHistory.ts) from the provider-limits + * subsystem (providerLimits.ts). usageHistory must NOT import providerLimits: + * providerLimits pulls in the executors barrel (and the whole translator graph), + * so a direct or dynamic import from usageHistory expands the type-check surface + * across modules that have nothing to do with usage recording. usageHistory + * emits here; providerLimits subscribes at module load. + * + * @module lib/usage/usageEvents + */ + +export type UsageRecordedListener = (provider: string, connectionId: string) => void; + +const listeners = new Set(); + +/** Register a listener for usage-recorded events. Returns an unsubscribe fn. */ +export function onUsageRecorded(listener: UsageRecordedListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Emit a usage-recorded event. No-ops when provider/connectionId is missing. */ +export function emitUsageRecorded( + provider: string | null | undefined, + connectionId: string | null | undefined +): void { + if (!provider || !connectionId) return; + for (const listener of listeners) { + try { + listener(provider, connectionId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[usageEvents] usage-recorded listener failed: ${message}`); + } + } +} diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index bea7f91db6..6e2b93394f 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -10,6 +10,7 @@ import { getDbInstance } from "../db/core"; import { protectPayloadForLog } from "../logPayloads"; import { shouldPersistToDisk } from "./migrations"; +import { emitUsageRecorded } from "./usageEvents"; import { getLoggedInputTokens, getLoggedOutputTokens, @@ -340,7 +341,8 @@ export function finalizeMostRecentPendingRequest( // detail from persisted call_log artifacts (best-effort, non-blocking). (async () => { try { - const missingProvider = updated.providerResponse === undefined || updated.providerResponse === null; + const missingProvider = + updated.providerResponse === undefined || updated.providerResponse === null; const missingClient = updated.clientResponse === undefined || updated.clientResponse === null; if ((missingProvider || missingClient) && connectionId) { const db = getDbInstance(); @@ -363,7 +365,10 @@ export function finalizeMostRecentPendingRequest( if (missingClient && pipeline?.clientResponse) { updated.clientResponse = pipeline.clientResponse; } - if ((missingProvider && art.artifact.responseBody) || (missingClient && art.artifact.responseBody)) { + if ( + (missingProvider && art.artifact.responseBody) || + (missingClient && art.artifact.responseBody) + ) { // use responseBody as a fallback for both if (missingProvider) updated.providerResponse = art.artifact.responseBody; if (missingClient) updated.clientResponse = art.artifact.responseBody; @@ -376,7 +381,12 @@ export function finalizeMostRecentPendingRequest( } } } catch (e) { - try { console.warn("[usageHistory] failed to enrich completed detail from artifacts:", (e && (e.message || e))); } catch {} + try { + console.warn( + "[usageHistory] failed to enrich completed detail from artifacts:", + e && (e.message || e) + ); + } catch {} } })(); @@ -582,6 +592,10 @@ export async function saveRequestUsage(entry: any) { entry.comboStrategy || entry.combo_strategy || null, timestamp ); + + // Decoupled via the event bus so usageHistory never imports providerLimits + // (which would pull the executors/translator graph into the type-check surface). + emitUsageRecorded(entry.provider, entry.connectionId); } catch (error) { console.error("Failed to save usage stats:", error); } diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 8058f5fe2e..38bf86ef57 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -16,6 +16,42 @@ import { createLogger } from "@/shared/utils/logger.ts"; const log = createLogger("mitm-manager"); +/** + * Map the MITM child process (`server.cjs`) stderr to the actual startup-failure + * cause. `server.cjs` emits one of several "❌"-prefixed lines on `server.on("error")` + * or on a missing API key, then exits. The old code only matched EADDRINUSE and so + * always blamed "port 443", misleading users whose real problem was a permission + * error or a missing ROUTER_API_KEY (#3606). The returned string is a controlled, + * secret-free diagnostic (it carries no stack and no credentials). (#3606) + */ +export function interpretMitmStartupError(stderr: string, port: number): string { + const text = (stderr || "").trim(); + const lower = text.toLowerCase(); + + if (lower.includes("already in use")) { + return `MITM server failed to start: port ${port} is already in use`; + } + if (lower.includes("permission denied")) { + return `MITM server failed to start: permission denied for port ${port} (run with elevated privileges, or use a port ≥ 1024)`; + } + if (lower.includes("router_api_key")) { + return "MITM server failed to start: no API key was provided (ROUTER_API_KEY is required). Set a router API key in OmniRoute and retry."; + } + + // Surface the first "❌ " diagnostic line verbatim (marker stripped), + // so any other server.cjs failure is reported with its real cause. + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith("❌")) { + const detail = trimmed.replace(/^❌\s*/, "").trim(); + if (detail) return `MITM server failed to start: ${detail}`; + } + } + + // Nothing diagnostic was captured — stay generic instead of guessing port 443. + return "MITM server failed to start (no diagnostic output was captured from the MITM server)"; +} + // Store server process let serverProcess: ChildProcess | null = null; let serverPid: number | null = null; @@ -314,13 +350,19 @@ export async function startMitm( fs.writeFileSync(PID_FILE, String(serverPid)); } + // Buffer recent stderr so a startup failure can be reported with its real + // cause (capped to avoid unbounded growth on a chatty/looping process). (#3606) + let stderrBuffer = ""; + // Log server output proc.stdout?.on("data", (data) => { log.info({ source: "mitm-server" }, data.toString().trim()); }); proc.stderr?.on("data", (data) => { - log.error({ source: "mitm-server" }, data.toString().trim()); + const chunk = data.toString(); + stderrBuffer = (stderrBuffer + chunk).slice(-4000); + log.error({ source: "mitm-server" }, chunk.trim()); }); proc.on("exit", (code) => { @@ -354,10 +396,11 @@ export async function startMitm( } }); - // Check stderr for error messages + // Fail fast on any "❌" diagnostic line from server.cjs (covers EADDRINUSE, + // EACCES, missing ROUTER_API_KEY, and any other server.on("error") cause). proc.stderr?.on("data", (data) => { - const msg = data.toString().trim(); - if (msg.includes("Port") && msg.includes("already in use")) { + const msg = data.toString(); + if (msg.includes("❌")) { clearTimeout(timeout); if (!resolved) { resolved = true; @@ -368,7 +411,7 @@ export async function startMitm( }); if (!started) { - throw new Error("MITM server failed to start (port 443 may be in use)"); + throw new Error(interpretMitmStartupError(stderrBuffer, port)); } return { diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index f47d9d0a02..3b52caa560 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -108,7 +108,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset 429, message, 0, - "gemini-3-flash-agent", + "gemini-3.5-flash-high", "antigravity", null, makeProfile() @@ -124,7 +124,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset test("recordModelLockoutFailure honors a multi-day exactCooldownMs (under 30-day cap)", () => { const provider = "antigravity"; const connectionId = "conn-quota-window"; - const model = "gemini-3-flash-agent"; + const model = "gemini-3.5-flash-high"; const exactCooldownMs = (164 * 3600 + 27 * 60 + 24) * 1000; clearModelLock(provider, connectionId, model); diff --git a/tests/unit/agy-provider.test.ts b/tests/unit/agy-provider.test.ts index b7c922e4f5..13ed8097c1 100644 --- a/tests/unit/agy-provider.test.ts +++ b/tests/unit/agy-provider.test.ts @@ -4,7 +4,10 @@ import assert from "node:assert/strict"; import { AI_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; import { PROVIDERS as LEGACY_PROVIDERS } from "../../open-sse/config/constants.ts"; -import { PROVIDERS as OAUTH_PROVIDER_IDS, AGY_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { + PROVIDERS as OAUTH_PROVIDER_IDS, + AGY_CONFIG, +} from "../../src/lib/oauth/constants/oauth.ts"; import { supportsTokenRefresh, REFRESH_LEAD_MS } from "../../open-sse/services/tokenRefresh.ts"; import { AGY_PUBLIC_MODELS, @@ -47,6 +50,12 @@ test("agy ships its own catalog including the Claude models antigravity omits", const ids = REGISTRY.agy.models.map((m) => m.id); assert.ok(ids.includes("claude-opus-4-6-thinking"), "must expose Claude Opus 4.6 Thinking"); assert.ok(ids.includes("claude-sonnet-4-6"), "must expose Claude Sonnet 4.6"); + assert.ok(ids.includes("gemini-3.5-flash-low"), "must expose clean Flash Low tier"); + assert.ok(ids.includes("gemini-3.5-flash-medium"), "must expose clean Flash Medium tier"); + assert.ok(ids.includes("gemini-3.5-flash-high"), "must expose clean Flash High tier"); + assert.ok(!ids.includes("gemini-3-flash-agent")); + assert.ok(!ids.includes("gemini-3-flash")); + assert.ok(!ids.includes("gemini-3.5-flash-extra-low")); // Tab-completion models are not chat-callable and must be excluded. assert.ok(!ids.includes("tab_flash_lite_preview")); assert.ok(!ids.includes("tab_jump_flash_lite_preview")); diff --git a/tests/unit/agy-usage-quota.test.ts b/tests/unit/agy-usage-quota.test.ts index 61213df837..a99f8e1ac9 100644 --- a/tests/unit/agy-usage-quota.test.ts +++ b/tests/unit/agy-usage-quota.test.ts @@ -1,4 +1,4 @@ -import test, { mock } from "node:test"; +import test from "node:test"; import assert from "node:assert/strict"; const usageModule = await import("../../open-sse/services/usage.ts"); @@ -13,19 +13,21 @@ test("agy is registered for the background usage fetcher", () => { }); test("getUsageForProvider routes agy through the Antigravity usage implementation", async () => { - const fetchMock = mock.method(globalThis, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: 0.75, - resetTime: "2026-06-06T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + models: { + "gemini-3-flash-agent": { + quotaInfo: { + remainingFraction: 0.75, + resetTime: "2026-06-06T00:00:00Z", + }, }, }, - }, - }), - })); + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); try { const result = await usageModule.getUsageForProvider( @@ -46,11 +48,16 @@ test("getUsageForProvider routes agy through the Antigravity usage implementatio ); assert.ok("quotas" in result, "agy should return quota data when upstream responds"); - const quota = (result as { quotas: Record }).quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should expose the Antigravity per-model quota for agy"); + const quota = (result as { quotas: Record }).quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should expose the clean agy per-model quota"); assert.equal(quota.remainingPercentage, 75); + assert.equal( + (result as { quotas: Record }).quotas["gemini-3-flash-agent"], + undefined, + "agy quota should not expose retired upstream IDs" + ); } finally { - fetchMock.mock.restore(); + globalThis.fetch = originalFetch; } }); @@ -58,7 +65,7 @@ test("parseQuotaData treats agy quota payloads like Antigravity", () => { const parsed = providerLimitUtils.parseQuotaData("agy", { quotas: { credits: { remaining: 42 }, - "gemini-3.5-flash-preview": { + "gemini-3.5-flash-high": { used: 250, total: 1000, remainingPercentage: 75, @@ -76,7 +83,7 @@ test("parseQuotaData treats agy quota payloads like Antigravity", () => { assert.ok(credits, "credits quota should be rendered"); assert.equal(credits.isCredits, true); - const modelQuota = parsed.find((quota: any) => quota.name === "gemini-3.5-flash-preview"); + const modelQuota = parsed.find((quota: any) => quota.name === "gemini-3.5-flash-high"); assert.ok(modelQuota, "model quota should be rendered"); assert.equal(modelQuota.remainingPercentage, 75); }); diff --git a/tests/unit/antigravity-local-usage-fallback-3821.test.ts b/tests/unit/antigravity-local-usage-fallback-3821.test.ts new file mode 100644 index 0000000000..16e5aa9724 --- /dev/null +++ b/tests/unit/antigravity-local-usage-fallback-3821.test.ts @@ -0,0 +1,119 @@ +/** + * LEDGER-3 (#3821-review) — the Antigravity local-usage fallback (#3604) replaces a + * stale full `fetchAvailableModels` bucket (used=0) with real consumption summed from + * `usage_history`, flipping quotaSource to "localUsageHistory". Every prior #3604 test + * mocks only the HTTP layer, so the model-id match against usage_history.model was never + * exercised end-to-end. This seeds a real usage_history row keyed by the CLIENT tier id + * the fallback queries and asserts the flip — the regression guard for the id contract. + * + * Contract note: the fallback queries `usage_history WHERE model = ` + * (e.g. gemini-3.5-flash-high), so the executor MUST log usage under that same client id + * for the fallback to fire. This test pins exactly that join. + */ +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-local-usage-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-ag-local-usage-secret"; + +const core = await import("../../src/lib/db/core.ts"); +// Load usage.ts up-front (its index.ts proxyFetch patch runs at module eval) before mocks. +const usageModule = await import("../../open-sse/services/usage.ts"); +const { getUsageForProvider } = usageModule; + +const originalFetch = globalThis.fetch; + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Antigravity fetchAvailableModels(used=0) → localUsageHistory when usage_history has rows", async () => { + core.resetDbInstance(); + + // resetTime an hour out → the 5h local-usage window is [now-4h, now+1h). + const resetTime = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const seededTimestamp = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // within window + + // Seed a usage_history row keyed by the CLIENT tier id the fallback queries. + const db = core.getDbInstance() as unknown as { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } }; + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, tokens_reasoning, success, timestamp) + VALUES (?, ?, ?, ?, ?, ?, 1, ?)` + ).run("antigravity", "gemini-3.5-flash-high", "conn-local-1", 1000, 1500, 500, seededTimestamp); + // Total seeded tokens = 3000 → ceil(3000/1000) = 3 units used. + + globalThis.fetch = (async (input: any) => { + const url = typeof input === "string" ? input : input?.url || ""; + // retrieveUserQuota (the live signal) is unavailable → falls back to fetchAvailableModels. + if (url.includes("retrieveUserQuota")) { + return { ok: false, status: 404, json: async () => ({}) } as Response; + } + // fetchAvailableModels returns a FULL (stale) bucket: remainingFraction 1.0 + resetTime. + return { + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { remainingFraction: 1.0, resetTime }, + }, + }, + }), + } as Response; + }) as typeof fetch; + + const connection = { + id: "conn-local-1", + provider: "antigravity", + accessToken: "fake-token-local-usage-unique", + providerSpecificData: {}, + projectId: undefined, + }; + + const result = await getUsageForProvider(connection, { forceRefresh: true }); + assert.ok(result && "quotas" in result, "should return quotas"); + const quota = (result as any).quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have the gemini-3.5-flash-high quota"); + assert.equal(quota.quotaSource, "localUsageHistory", "stale full bucket replaced by local usage"); + assert.equal(quota.used, 3, "3000 seeded tokens → 3 units used"); +}); + +test("Antigravity stays fetchAvailableModels when usage_history has no matching rows", async () => { + core.resetDbInstance(); + + const resetTime = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + globalThis.fetch = (async (input: any) => { + const url = typeof input === "string" ? input : input?.url || ""; + if (url.includes("retrieveUserQuota")) { + return { ok: false, status: 404, json: async () => ({}) } as Response; + } + return { + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { quotaInfo: { remainingFraction: 1.0, resetTime } }, + }, + }), + } as Response; + }) as typeof fetch; + + const connection = { + id: "conn-local-2", + provider: "antigravity", + accessToken: "fake-token-local-usage-empty", + providerSpecificData: {}, + projectId: undefined, + }; + + const result = await getUsageForProvider(connection, { forceRefresh: true }); + const quota = (result as any).quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have the quota"); + assert.equal(quota.quotaSource, "fetchAvailableModels", "no local rows → keep the catalog view"); + assert.equal(quota.used, 0, "full bucket stays at 0 used"); +}); diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 511ac5dc9c..cb16ab8764 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -7,6 +7,7 @@ import { isUserCallableAntigravityModelId, resolveAntigravityModelId, toClientAntigravityModelId, + toClientAntigravityQuotaModelId, } from "../../open-sse/config/antigravityModelAliases.ts"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; @@ -15,15 +16,36 @@ function getPublicModel(id: string) { return ANTIGRAVITY_PUBLIC_MODELS.find((model) => model.id === id) as any; } +// #3821-review LEDGER-5 — the upstream quota-bucket → client-tier remap is now the single +// source of truth here (was duplicated as an inline if-ladder in usage.ts). It operates on +// the UPSTREAM quota namespace, where `gemini-3.5-flash-low` is the Medium tier's bucket. +test("toClientAntigravityQuotaModelId maps upstream quota buckets to client tiers", () => { + assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-extra-low"), "gemini-3.5-flash-low"); + // Dual-meaning id: in the quota namespace this bucket is the Medium tier. + assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-medium"); + assert.equal(toClientAntigravityQuotaModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high"); + // Non-tier ids fall back to the standard reverse alias map. + assert.equal(toClientAntigravityQuotaModelId("gemini-3.1-pro"), "gemini-3-pro-preview"); + // Always-allowed bucket passes through unchanged. + assert.equal(toClientAntigravityQuotaModelId("credits"), "credits"); + // Retired preview buckets are dropped (hidden from clients). + assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-preview"), null); + assert.equal(toClientAntigravityQuotaModelId("gemini-3-flash-preview"), null); + assert.equal(toClientAntigravityQuotaModelId(""), null); +}); + test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => { assert.equal(resolveAntigravityModelId("gemini-3-pro-preview"), "gemini-3.1-pro"); - assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3.5-flash"); - assert.equal(resolveAntigravityModelId("gemini-3-flash-preview"), "gemini-3-flash"); assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image"); assert.equal( resolveAntigravityModelId("gemini-2.5-computer-use-preview-10-2025"), "rev19-uic3-1p" ); + assert.equal(resolveAntigravityModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-extra-low"); + assert.equal(resolveAntigravityModelId("gemini-3.5-flash-medium"), "gemini-3.5-flash-low"); + assert.equal(resolveAntigravityModelId("gemini-3.5-flash-high"), "gemini-3-flash-agent"); + // Backward-compat: retired flagship public id routes to the High tier upstream. + assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3-flash-agent"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6"); assert.equal( @@ -35,8 +57,8 @@ test("resolveAntigravityModelId maps the documented Antigravity aliases to upstr test("toClientAntigravityModelId exposes client-visible aliases for known upstream IDs", () => { assert.equal(toClientAntigravityModelId("gemini-3.1-pro"), "gemini-3-pro-preview"); - assert.equal(toClientAntigravityModelId("gemini-3-flash-agent"), "gemini-3.5-flash-preview"); - assert.equal(toClientAntigravityModelId("gemini-3-flash"), "gemini-3-flash-preview"); + assert.equal(toClientAntigravityModelId("gemini-3.5-flash-extra-low"), "gemini-3.5-flash-low"); + assert.equal(toClientAntigravityModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high"); assert.equal(toClientAntigravityModelId("gpt-oss-120b-medium"), "gpt-oss-120b-medium"); assert.equal(toClientAntigravityModelId("claude-sonnet-4-6"), "claude-sonnet-4-6"); assert.equal(toClientAntigravityModelId("claude-opus-4-6-thinking"), "claude-opus-4-6-thinking"); @@ -45,6 +67,8 @@ test("toClientAntigravityModelId exposes client-visible aliases for known upstre test("isUserCallableAntigravityModelId only allows public chat-capable model IDs", () => { assert.equal(isUserCallableAntigravityModelId("gemini-3-pro-preview"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro"), true); + // Retired flagship id stays callable as a hidden backward-compat alias (routes to High), + // even though it is no longer exposed in the public catalog. assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-preview"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3-flash-agent"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.1-flash-lite"), true); @@ -58,10 +82,12 @@ test("isUserCallableAntigravityModelId only allows public chat-capable model IDs // 2.0 was wrong. assert.equal(isUserCallableAntigravityModelId("claude-opus-4-6-thinking"), true); assert.equal(isUserCallableAntigravityModelId("claude-sonnet-4-6"), true); - // #3184: Gemini budget tiers now exposed (agy parity). + // Antigravity 2.0.4 exposes Gemini 3.5 Flash as separate UI tiers. assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-high"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-low"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-low"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-medium"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-high"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-extra-low"), true); assert.equal(isUserCallableAntigravityModelId("tab_flash_lite_preview"), false); assert.equal(isUserCallableAntigravityModelId("unknown-model"), false); @@ -79,9 +105,9 @@ test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and cap toolCalling: true, }); assert.equal(getPublicModel("claude-sonnet-4-6").name, "Claude Sonnet 4.6 (Thinking)"); - assert.deepEqual(getPublicModel("gemini-3.5-flash-preview"), { - id: "gemini-3.5-flash-preview", - name: "Gemini 3.5 Flash", + assert.deepEqual(getPublicModel("gemini-3.5-flash-high"), { + id: "gemini-3.5-flash-high", + name: "Gemini 3.5 Flash (High)", contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, @@ -89,8 +115,8 @@ test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and cap toolCalling: true, }); assert.equal( - getClientVisibleAntigravityModelName("gemini-3.5-flash-preview"), - "Gemini 3.5 Flash" + getClientVisibleAntigravityModelName("gemini-3.5-flash-medium"), + "Gemini 3.5 Flash (Medium)" ); assert.equal(getClientVisibleAntigravityModelName("gemini-2.5-flash"), "Gemini 2.5 Flash"); assert.equal( @@ -127,15 +153,6 @@ test("ANTIGRAVITY_PUBLIC_MODELS has no duplicate model IDs", () => { assert.deepEqual(duplicates, [], `duplicate model IDs found: ${duplicates.join(", ")}`); }); -test("gemini-3-flash-agent keeps its Agent display name (not the Flash High duplicate)", () => { - // A duplicate entry previously overwrote this name with "Gemini 3.5 Flash (High)" - // because the id-keyed name map kept the last occurrence. - assert.equal( - getClientVisibleAntigravityModelName("gemini-3-flash-agent"), - "Gemini 3.5 Flash Agent" - ); -}); - test("AntigravityExecutor.transformRequest resolves alias models before dispatching upstream", async () => { const executor = new AntigravityExecutor(); const result = await executor.transformRequest( @@ -153,10 +170,10 @@ test("AntigravityExecutor.transformRequest resolves alias models before dispatch assert.equal(result.model, "gemini-3.1-pro"); }); -test("AntigravityExecutor.transformRequest resolves Gemini 3.5 Flash alias upstream", async () => { +test("AntigravityExecutor.transformRequest maps Gemini 3.5 Flash tiers to live upstream IDs", async () => { const executor = new AntigravityExecutor(); const result = await executor.transformRequest( - "antigravity/gemini-3.5-flash-preview", + "antigravity/gemini-3.5-flash-high", { request: { contents: [{ role: "user", parts: [{ text: "Hello" }] }], @@ -167,7 +184,14 @@ test("AntigravityExecutor.transformRequest resolves Gemini 3.5 Flash alias upstr ); if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); - assert.equal(result.model, "gemini-3.5-flash"); + // The "High" tier resolves to the live upstream id; the request body is forwarded + // under that id. (Dropped four assertions on modelConfigId/model_config_id — the + // executor never sets those fields, so they were vacuously true and gave false + // confidence. #3821-review LEDGER-10.) + assert.equal(result.model, "gemini-3-flash-agent"); + assert.deepEqual(result.request.contents, [ + { role: "user", parts: [{ text: "Hello" }] }, + ]); }); test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => { diff --git a/tests/unit/antigravity-usage-service.test.ts b/tests/unit/antigravity-usage-service.test.ts index 69985175c1..30d1f0d6b8 100644 --- a/tests/unit/antigravity-usage-service.test.ts +++ b/tests/unit/antigravity-usage-service.test.ts @@ -9,7 +9,7 @@ * - 0.5 → 50% remaining (partial quota) */ -import { describe, it, mock } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert/strict"; // IMPORTANT: load usage.ts up-front so the proxyFetch patch in @@ -29,19 +29,21 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { }; it("defaults to 0% remaining when remainingFraction is undefined", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: undefined, - resetTime: "2026-05-26T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: undefined, + resetTime: "2026-05-26T00:00:00Z", + }, }, }, - }, - }), - })); + }), + }) as Response; try { const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); @@ -49,31 +51,33 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + const quota = result.quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-high"); assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); assert.equal(quota.unlimited, false, "should not be unlimited"); assert.equal(quota.used > 0, true, "used should be > 0 when quota is exhausted"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); it("parses remainingFraction=0 as exhausted quota", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: 0, - resetTime: "2026-05-26T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: 0, + resetTime: "2026-05-26T00:00:00Z", + }, }, }, - }, - }), - })); + }), + }) as Response; try { const usageModule = await import("../../open-sse/services/usage.ts"); @@ -84,30 +88,32 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + const quota = result.quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-high"); assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); assert.equal(quota.unlimited, false, "should not be unlimited"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); it("parses remainingFraction=1.0 with resetTime as full quota", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: 1.0, - resetTime: "2026-05-26T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: 1.0, + resetTime: "2026-05-26T00:00:00Z", + }, }, }, - }, - }), - })); + }), + }) as Response; try { const usageModule = await import("../../open-sse/services/usage.ts"); @@ -118,29 +124,31 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + const quota = result.quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-high"); assert.equal(quota.remainingPercentage, 100, "remaining should be 100%"); assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); it("parses remainingFraction=1.0 without resetTime as unlimited (e.g. tab-completion)", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.1-flash-lite": { - quotaInfo: { - remainingFraction: 1.0, + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.1-flash-lite": { + quotaInfo: { + remainingFraction: 1.0, + }, }, }, - }, - }), - })); + }), + }) as Response; try { const usageModule = await import("../../open-sse/services/usage.ts"); @@ -157,24 +165,26 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.equal(quota.unlimited, true, "should be unlimited (no resetTime)"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); it("parses remainingFraction=0.5 as partial quota", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: 0.5, - resetTime: "2026-05-26T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: 0.5, + resetTime: "2026-05-26T00:00:00Z", + }, }, }, - }, - }), - })); + }), + }) as Response; try { const usageModule = await import("../../open-sse/services/usage.ts"); @@ -185,30 +195,32 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + const quota = result.quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-high"); assert.equal(quota.remainingPercentage, 50, "remaining should be 50%"); assert.equal(quota.unlimited, false, "should not be unlimited"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); it("clamps remainingFraction > 1 to 100%", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: 1.5, - resetTime: "2026-05-26T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: 1.5, + resetTime: "2026-05-26T00:00:00Z", + }, }, }, - }, - }), - })); + }), + }) as Response; try { const usageModule = await import("../../open-sse/services/usage.ts"); @@ -219,30 +231,32 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + const quota = result.quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-high"); assert.equal(quota.remainingPercentage, 100, "remaining should be clamped to 100%"); assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); it("clamps negative remainingFraction to 0%", async () => { - const mockFetch = mock.method(global, "fetch", async () => ({ - ok: true, - json: async () => ({ - models: { - "gemini-3.5-flash-preview": { - quotaInfo: { - remainingFraction: -0.5, - resetTime: "2026-05-26T00:00:00Z", + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: -0.5, + resetTime: "2026-05-26T00:00:00Z", + }, }, }, - }, - }), - })); + }), + }) as Response; try { const usageModule = await import("../../open-sse/services/usage.ts"); @@ -253,13 +267,13 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { assert.ok("quotas" in result, "should have quotas"); if ("quotas" in result) { - const quota = result.quotas["gemini-3.5-flash-preview"]; - assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + const quota = result.quotas["gemini-3.5-flash-high"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-high"); assert.equal(quota.remainingPercentage, 0, "remaining should be clamped to 0%"); assert.equal(quota.unlimited, false, "should not be unlimited"); } } finally { - mockFetch.mock.restore(); + globalThis.fetch = originalFetch; } }); }); diff --git a/tests/unit/autostart-cli-shorthand-3331.test.ts b/tests/unit/autostart-cli-shorthand-3331.test.ts new file mode 100644 index 0000000000..7c7becb155 --- /dev/null +++ b/tests/unit/autostart-cli-shorthand-3331.test.ts @@ -0,0 +1,47 @@ +/** + * Regression test for #3331 — autostart could only be toggled from the tray + * (`serve --tray`) or the Electron Appearance tab; a plain `omniroute serve` + * user had no way to enable it. The `autostart` command now exposes the + * shorthand the reporter asked for (`omniroute autostart on` / `... true`) via + * aliases on the enable/disable subcommands, plus a `toggle` subcommand and a + * default `status`. This guards that command wiring (introspected, no platform + * side effects). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Command } from "commander"; +import { registerAutostart } from "../../bin/cli/commands/autostart.mjs"; + +function buildAutostartCommand() { + const program = new Command(); + registerAutostart(program); + const autostart = program.commands.find((c) => c.name() === "autostart"); + assert.ok(autostart, "autostart command should be registered"); + return autostart; +} + +test("registers enable/disable/toggle/status subcommands", () => { + const names = buildAutostartCommand() + .commands.map((c) => c.name()) + .sort(); + assert.deepEqual(names, ["disable", "enable", "status", "toggle"]); +}); + +test("enable accepts the on/true shorthand aliases (reporter asked for `autostart true`)", () => { + const enable = buildAutostartCommand().commands.find((c) => c.name() === "enable"); + assert.ok(enable.aliases().includes("on"), "`autostart on` should enable"); + assert.ok(enable.aliases().includes("true"), "`autostart true` should enable"); +}); + +test("disable accepts the off/false shorthand aliases", () => { + const disable = buildAutostartCommand().commands.find((c) => c.name() === "disable"); + assert.ok(disable.aliases().includes("off"), "`autostart off` should disable"); + assert.ok(disable.aliases().includes("false"), "`autostart false` should disable"); +}); + +test("status is the default action (bare `omniroute autostart` is a safe read-only)", () => { + const status = buildAutostartCommand().commands.find((c) => c.name() === "status"); + // Commander marks the default subcommand with `_defaultCommandName`. + assert.equal(buildAutostartCommand()._defaultCommandName, "status"); + assert.ok(status, "status subcommand exists"); +}); diff --git a/tests/unit/chatcore-extracted-modules-3821.test.ts b/tests/unit/chatcore-extracted-modules-3821.test.ts new file mode 100644 index 0000000000..ab7b8d66fe --- /dev/null +++ b/tests/unit/chatcore-extracted-modules-3821.test.ts @@ -0,0 +1,110 @@ +/** + * LEDGER-6 (#3821-review) — the chatCore modularization (#3598) relocated ~300 lines into + * open-sse/handlers/chatCore/{idempotency,sanitization,semanticCache,memorySkillsInjection}.ts + * with no direct tests at the new seam. The extraction shipped a real `ReferenceError` + * (idempotencyKey) that no test caught. These tests pin the pure/extractable pieces: + * - sanitizeChatRequestBody (token-field normalization, empty-name stripping, tool filter) + * - checkIdempotencyCache now returns { hit, idempotencyKey } so the save site reuses the + * single derivation (no dual getIdempotencyKey call). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { sanitizeChatRequestBody } from "../../open-sse/handlers/chatCore/sanitization.ts"; +import { checkIdempotencyCache } from "../../open-sse/handlers/chatCore/idempotency.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { saveIdempotency } from "../../src/lib/idempotencyLayer.ts"; + +test("sanitizeChatRequestBody: Chat Completions target maps max_output_tokens → max_tokens", () => { + const out = sanitizeChatRequestBody({ max_output_tokens: 256 }, FORMATS.OPENAI, FORMATS.OPENAI); + assert.equal(out.max_tokens, 256); + assert.equal(out.max_output_tokens, undefined); +}); + +test("sanitizeChatRequestBody: Responses target maps max_completion_tokens → max_output_tokens", () => { + const out = sanitizeChatRequestBody( + { max_completion_tokens: 512 }, + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES + ); + assert.equal(out.max_output_tokens, 512); + assert.equal(out.max_completion_tokens, undefined); +}); + +test("sanitizeChatRequestBody: Responses target maps max_tokens → max_output_tokens", () => { + const out = sanitizeChatRequestBody({ max_tokens: 128 }, FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI); + assert.equal(out.max_output_tokens, 128); + assert.equal(out.max_tokens, undefined); +}); + +test("sanitizeChatRequestBody: strips empty message name and filters nameless tools", () => { + const out = sanitizeChatRequestBody( + { + messages: [ + { role: "user", content: "hi", name: "" }, + { role: "assistant", content: "yo", name: "keepme" }, + ], + tools: [ + { type: "function", function: { name: "real_tool", parameters: {} } }, + { type: "function", function: { name: "" } }, // dropped — empty name + { type: "function", function: {} }, // dropped — no name + ], + }, + FORMATS.OPENAI, + FORMATS.OPENAI + ); + + const messages = out.messages as Array>; + assert.ok(!("name" in messages[0]), "empty name stripped"); + assert.equal(messages[1].name, "keepme", "non-empty name kept"); + + const tools = out.tools as Array>; + assert.equal(tools.length, 1, "only the named tool survives"); + assert.equal((tools[0].function as Record).name, "real_tool"); +}); + +test("checkIdempotencyCache returns { hit:null, idempotencyKey } on a miss", async () => { + const headers = new Headers({ "idempotency-key": "idem-miss-3821" }); + const result = await checkIdempotencyCache({ + clientRawRequest: { headers }, + provider: "openai", + model: "gpt-4.1", + effectiveServiceTier: undefined, + startTime: 0, + log: undefined, + }); + assert.equal(result.hit, null); + assert.equal(result.idempotencyKey, "idem-miss-3821"); +}); + +test("checkIdempotencyCache returns a hit Response reusing the same key after a save", async () => { + const key = "idem-hit-3821"; + saveIdempotency(key, { object: "chat.completion", choices: [], usage: {} }, 200); + + const headers = new Headers({ "idempotency-key": key }); + const result = await checkIdempotencyCache({ + clientRawRequest: { headers }, + provider: "openai", + model: "gpt-4.1", + effectiveServiceTier: undefined, + startTime: 0, + log: undefined, + }); + + assert.equal(result.idempotencyKey, key, "the resolved key is returned for the save site to reuse"); + assert.ok(result.hit, "a cached entry produces a hit"); + assert.equal(result.hit!.response.headers.get("X-OmniRoute-Idempotent"), "true"); +}); + +test("checkIdempotencyCache resolves a null key when no idempotency headers are present", async () => { + const result = await checkIdempotencyCache({ + clientRawRequest: { headers: new Headers() }, + provider: "openai", + model: "gpt-4.1", + effectiveServiceTier: undefined, + startTime: 0, + log: undefined, + }); + assert.equal(result.hit, null); + assert.equal(result.idempotencyKey, null); +}); diff --git a/tests/unit/completions-text-format-3571.test.ts b/tests/unit/completions-text-format-3571.test.ts new file mode 100644 index 0000000000..890c792516 --- /dev/null +++ b/tests/unit/completions-text-format-3571.test.ts @@ -0,0 +1,156 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + toTextCompletionObject, + transformSseData, + createTextCompletionStreamTransformer, + asTextCompletionResponse, +} from "../../src/app/api/v1/completions/textCompletionTransform.ts"; + +// #3571 — /v1/completions (legacy OpenAI Completions API) must return +// `object: "text_completion"` with `choices[].text`, not the chat shape +// (`chat.completion(.chunk)` with `choices[].message|delta.content`), which crashes +// TabbyML's `openai/completion` backend ("missing field `text`"). + +test("#3571 non-stream: chat.completion → text_completion with choices[].text", () => { + const out = toTextCompletionObject({ + id: "chatcmpl-1", + object: "chat.completion", + created: 1, + model: "ds/deepseek-v4-flash", + choices: [ + { index: 0, message: { role: "assistant", content: "public class Test {}" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }, + }); + assert.equal(out.object, "text_completion"); + assert.equal(out.choices[0].text, "public class Test {}"); + assert.equal(out.choices[0].finish_reason, "stop"); + assert.equal(out.choices[0].index, 0); + assert.equal(out.choices[0].logprobs, null); + assert.equal(out.choices[0].message, undefined); // no chat shape leaks + assert.deepEqual(out.usage, { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }); +}); + +test("#3571 stream chunk: chat.completion.chunk(delta) → text_completion with text", () => { + const out = toTextCompletionObject({ + id: "chatcmpl-2", + object: "chat.completion.chunk", + created: 2, + model: "gpt-5.5", + choices: [{ index: 0, delta: { content: "Hi" }, finish_reason: null }], + }); + assert.equal(out.object, "text_completion"); + assert.equal(out.choices[0].text, "Hi"); + assert.equal(out.choices[0].delta, undefined); +}); + +test("#3571 transformSseData: passes [DONE] and non-JSON through, rewrites chat JSON", () => { + assert.equal(transformSseData("[DONE]"), "[DONE]"); + assert.equal(transformSseData(" "), ""); + assert.equal(transformSseData("not json"), "not json"); + const rewritten = JSON.parse( + transformSseData('{"object":"chat.completion.chunk","choices":[{"delta":{"content":"X"}}]}') + ); + assert.equal(rewritten.object, "text_completion"); + assert.equal(rewritten.choices[0].text, "X"); +}); + +test("#3571 empty delta content → text:'' (never undefined → no 'missing field text')", () => { + const out = toTextCompletionObject({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + assert.equal(out.choices[0].text, ""); +}); + +test("#3571 stream transformer end-to-end: chat SSE → text SSE", async () => { + const encoder = new TextEncoder(); + const chatSse = + 'data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}\n\n' + + 'data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + + const source = new ReadableStream({ + start(controller) { + // split into two arbitrary byte chunks to exercise the line buffer across boundaries + const mid = Math.floor(chatSse.length / 2); + controller.enqueue(encoder.encode(chatSse.slice(0, mid))); + controller.enqueue(encoder.encode(chatSse.slice(mid))); + controller.close(); + }, + }); + + const out = source.pipeThrough(createTextCompletionStreamTransformer()); + const reader = out.getReader(); + const decoder = new TextDecoder(); + let result = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + + const dataLines = result + .split("\n") + .filter((l) => l.startsWith("data:") && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice("data:".length).trim())); + + assert.equal(dataLines.length, 2); + assert.ok(dataLines.every((o) => o.object === "text_completion")); + assert.equal(dataLines[0].choices[0].text, "Hello"); + assert.equal(dataLines[1].choices[0].text, " world"); + assert.equal(dataLines[1].choices[0].finish_reason, "stop"); + assert.ok(result.includes("data: [DONE]")); // [DONE] preserved + assert.ok(!result.includes("delta")); // no chat shape leaks +}); + +// #3821-review LEDGER-8 — both response branches rewrite the body, so a stale upstream +// content-length must be dropped (a buffered SSE body with content-length would otherwise +// advertise the pre-rewrite length and truncate/hang the client). +test("#3571/#3821 asTextCompletionResponse drops content-length on the SSE branch", async () => { + const sseBody = + 'data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n'; + const upstream = new Response(sseBody, { + status: 200, + headers: { + "content-type": "text/event-stream", + // A (deliberately wrong) content-length that must NOT survive the rewrite. + "content-length": String(sseBody.length), + }, + }); + + const out = await asTextCompletionResponse(upstream); + assert.equal(out.headers.get("content-length"), null, "content-length must be stripped"); + assert.match(out.headers.get("content-type") || "", /text\/event-stream/); + const text = await out.text(); + assert.ok(text.includes('"object":"text_completion"')); + assert.ok(text.includes('"text":"hi"')); +}); + +test("#3571/#3821 asTextCompletionResponse drops content-length on the JSON branch", async () => { + const jsonBody = JSON.stringify({ + object: "chat.completion", + choices: [{ index: 0, message: { content: "hi" }, finish_reason: "stop" }], + }); + const upstream = new Response(jsonBody, { + status: 200, + headers: { "content-type": "application/json", "content-length": String(jsonBody.length) }, + }); + + const out = await asTextCompletionResponse(upstream); + assert.equal(out.headers.get("content-length"), null); + const obj = await out.json(); + assert.equal(obj.object, "text_completion"); + assert.equal(obj.choices[0].text, "hi"); +}); + +test("#3571/#3821 asTextCompletionResponse passes error responses through untouched", async () => { + const upstream = new Response(JSON.stringify({ error: { message: "boom" } }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + const out = await asTextCompletionResponse(upstream); + assert.equal(out, upstream, "non-ok responses are returned as-is"); +}); diff --git a/tests/unit/empty-content-stopreason-3572.test.ts b/tests/unit/empty-content-stopreason-3572.test.ts new file mode 100644 index 0000000000..ea5fb4f8b1 --- /dev/null +++ b/tests/unit/empty-content-stopreason-3572.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isEmptyContentResponse } from "../../open-sse/services/errorClassifier.ts"; + +// #3572 — A valid max_tokens-truncated upstream response (HTTP 200, a legitimate +// terminal stop_reason/finish_reason, empty content) must NOT be misclassified as +// an empty/silent-failure response (which gets rewritten into a synthetic 502). +// The empty-content guard must only fire when content is empty AND there is no +// legitimate terminal stop_reason — i.e. a genuine fake-success / silent failure. + +test("#3572 Claude: empty content + stop_reason=max_tokens is NOT empty-failure", () => { + assert.equal( + isEmptyContentResponse({ + type: "message", + role: "assistant", + content: [], + stop_reason: "max_tokens", + usage: { output_tokens: 1 }, + }), + false + ); +}); + +test("#3572 Claude: empty content + stop_reason=tool_use is NOT empty-failure", () => { + assert.equal(isEmptyContentResponse({ content: [], stop_reason: "tool_use" }), false); +}); + +test("#3572 Claude: empty content with NO stop_reason IS still empty-failure", () => { + assert.equal(isEmptyContentResponse({ content: [] }), true); + assert.equal(isEmptyContentResponse({ content: [], stop_reason: null }), true); +}); + +test("#3572 Claude: empty content + stop_reason=end_turn stays flagged (fake-success guard preserved)", () => { + assert.equal(isEmptyContentResponse({ content: [], stop_reason: "end_turn" }), true); +}); + +test("#3572 OpenAI: empty content + finish_reason=length is NOT empty-failure", () => { + assert.equal( + isEmptyContentResponse({ + choices: [{ index: 0, message: { content: "" }, finish_reason: "length" }], + }), + false + ); +}); + +test("#3572 OpenAI: empty delta + finish_reason=length (stream chunk) is NOT empty-failure", () => { + assert.equal( + isEmptyContentResponse({ + choices: [{ index: 0, delta: { content: "" }, finish_reason: "length" }], + }), + false + ); +}); + +test("#3572 OpenAI: empty content + finish_reason=stop stays flagged (fake-success guard preserved)", () => { + assert.equal( + isEmptyContentResponse({ + choices: [{ index: 0, message: { content: "" }, finish_reason: "stop" }], + }), + true + ); +}); + +test("#3572 regression: non-empty content is never flagged", () => { + assert.equal(isEmptyContentResponse({ content: [{ type: "text", text: "hi" }] }), false); + assert.equal( + isEmptyContentResponse({ choices: [{ message: { content: "hi" }, finish_reason: "length" }] }), + false + ); +}); diff --git a/tests/unit/executor-agy.test.ts b/tests/unit/executor-agy.test.ts index 07975cb18d..1c30e74129 100644 --- a/tests/unit/executor-agy.test.ts +++ b/tests/unit/executor-agy.test.ts @@ -2,6 +2,17 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getExecutor, AntigravityExecutor } from "../../open-sse/executors/index.ts"; +import { processAntigravitySSEPayload } from "../../open-sse/executors/antigravity.ts"; + +function emptyCollected(): any { + return { + textContent: "", + finishReason: "", + toolCalls: [], + usage: null, + remainingCredits: null, + }; +} test("getExecutor('agy') returns AntigravityExecutor (not DefaultExecutor)", () => { const executor = getExecutor("agy"); @@ -10,12 +21,15 @@ test("getExecutor('agy') returns AntigravityExecutor (not DefaultExecutor)", () test("getExecutor('antigravity') returns AntigravityExecutor", () => { const executor = getExecutor("antigravity"); - assert.ok(executor instanceof AntigravityExecutor, "antigravity provider should use AntigravityExecutor"); + assert.ok( + executor instanceof AntigravityExecutor, + "antigravity provider should use AntigravityExecutor" + ); }); test("getExecutor('agy') builds valid streaming URL", () => { const executor = getExecutor("agy"); - const url = executor.buildUrl("gemini-3-flash", true); + const url = executor.buildUrl("gemini-3.5-flash-high", true); assert.ok( url.includes("streamGenerateContent?alt=sse"), `expected streaming endpoint URL, got: ${url}` @@ -24,7 +38,7 @@ test("getExecutor('agy') builds valid streaming URL", () => { test("getExecutor('agy') builds valid non-streaming URL", () => { const executor = getExecutor("agy"); - const url = executor.buildUrl("gemini-3-flash", false); + const url = executor.buildUrl("gemini-3.5-flash-high", false); // Antigravity executor always uses streaming endpoint (buildUrl ignores stream flag) assert.ok( url.includes("streamGenerateContent?alt=sse"), @@ -37,3 +51,27 @@ test("getExecutor('agy') buildHeaders returns Bearer auth", () => { const headers = executor.buildHeaders({ accessToken: "test-token" }); assert.equal(headers.Authorization, "Bearer test-token"); }); + +// #3821-review LEDGER-9 — the Antigravity SSE `markdown` extraction branch had no test. +test("processAntigravitySSEPayload accumulates top-level markdown into textContent", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload(JSON.stringify({ markdown: "Hello " }), collected); + processAntigravitySSEPayload(JSON.stringify({ response: { markdown: "world" } }), collected); + assert.equal(collected.textContent, "Hello world"); +}); + +test("processAntigravitySSEPayload uses candidate parts text when no markdown is present", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload( + JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "from parts" }] } }] } }), + collected + ); + assert.equal(collected.textContent, "from parts"); +}); + +test("processAntigravitySSEPayload ignores [DONE] and malformed payloads without throwing", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload("[DONE]", collected); + processAntigravitySSEPayload("{not json", collected); + assert.equal(collected.textContent, ""); +}); diff --git a/tests/unit/gamification/aggregate-profile-3484.test.ts b/tests/unit/gamification/aggregate-profile-3484.test.ts new file mode 100644 index 0000000000..59a1d156f1 --- /dev/null +++ b/tests/unit/gamification/aggregate-profile-3484.test.ts @@ -0,0 +1,79 @@ +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"; + +// #3484 — the dashboard profile page fetches /api/gamification/{level,badges,badges/earned} +// without an apiKeyId (operator-wide view). These helpers back the no-key case and the +// badge catalog must be seeded so the grid is populated (see #3472). + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-gami-3484-")); +process.env.DATA_DIR = TEST_DATA_DIR; +if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = "test-gami-3484-secret-" + Date.now(); +} + +const { getDbInstance, resetDbInstance } = await import("../../../src/lib/db/core.ts"); +const gami = await import("../../../src/lib/db/gamification.ts"); +const { seedBuiltinBadges, BUILTIN_BADGES } = await import("../../../src/lib/gamification/badges.ts"); + +test.after(() => { + try { + getDbInstance().close(); + } catch { + /* ignore */ + } + try { + resetDbInstance(); + } catch { + /* ignore */ + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#3484 getAggregateXp on an empty ledger → zero XP, level 1, no throw", () => { + const agg = gami.getAggregateXp(); + assert.equal(agg.totalXp, 0); + assert.equal(agg.currentLevel, 1); + assert.equal(agg.apiKeyId, "*"); +}); + +test("#3484 seedBuiltinBadges populates the catalog (getBadgeDefinitions non-empty)", async () => { + assert.equal(gami.getBadgeDefinitions().length, 0); // unseeded + await seedBuiltinBadges(); + assert.equal(gami.getBadgeDefinitions().length, BUILTIN_BADGES.length); + await seedBuiltinBadges(); // idempotent — no duplicates + assert.equal(gami.getBadgeDefinitions().length, BUILTIN_BADGES.length); +}); + +test("#3484 getAggregateXp sums XP across keys and takes the highest level", () => { + const db = getDbInstance(); + const upsert = db.prepare( + `INSERT OR REPLACE INTO user_levels (api_key_id, total_xp, current_level, updated_at) + VALUES (?, ?, ?, datetime('now'))` + ); + upsert.run("key-a", 100, 2); + upsert.run("key-b", 250, 5); + + const agg = gami.getAggregateXp(); + assert.equal(agg.totalXp, 350); + assert.equal(agg.currentLevel, 5); +}); + +test("#3484 getAllEarnedBadges returns distinct badges earned by any key", () => { + const db = getDbInstance(); + const [b0, b1] = BUILTIN_BADGES; + const award = db.prepare( + `INSERT OR IGNORE INTO user_badges (api_key_id, badge_id, unlocked_at) + VALUES (?, ?, datetime('now'))` + ); + award.run("key-a", b0.id); // earned by A + award.run("key-b", b0.id); // same badge earned by B → must dedupe to one + award.run("key-b", b1.id); // distinct badge earned by B + + const earned = gami.getAllEarnedBadges(); + const ids = earned.map((e) => e.badgeId).sort(); + assert.deepEqual(ids, [b0.id, b1.id].sort()); + assert.ok(earned.every((e) => typeof e.badgeName === "string" && e.badgeName.length > 0)); +}); diff --git a/tests/unit/glm-coding-plan-monthly-3580.test.ts b/tests/unit/glm-coding-plan-monthly-3580.test.ts new file mode 100644 index 0000000000..418e9a9117 --- /dev/null +++ b/tests/unit/glm-coding-plan-monthly-3580.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { glmMonthlyRemainingPercentage } from "../../open-sse/services/usage.ts"; + +// #3580 — z.ai/GLM coding plans have no monthly cap (only 5-hour windows), so the +// quota API reports the TIME_LIMIT ("Monthly") entry with total=0. The previous +// `total > 0 ? … : 0` fallback rendered that as a misleading "Monthly 0% remaining", +// which can skew downstream model-choice. With no absolute cap we now fall back to the +// percentage-derived remaining (full/100% when 0% used). + +test("#3580 no monthly cap (total=0), 0% used → 100% remaining (was 0%)", () => { + assert.equal(glmMonthlyRemainingPercentage(0, 100), 100); +}); + +test("#3580 no monthly cap (total=0), no usage signal → defaults to full (100)", () => { + // remaining defaults to max(0, 100 - percentage); 0% used → 100 + assert.equal(glmMonthlyRemainingPercentage(0, 100), 100); +}); + +test("#3580 absolute monthly cap still computes remaining/total", () => { + assert.equal(glmMonthlyRemainingPercentage(1000, 500), 50); + assert.equal(glmMonthlyRemainingPercentage(1000, 1000), 100); + assert.equal(glmMonthlyRemainingPercentage(1000, 0), 0); +}); + +test("#3580 clamps out-of-range values to [0,100]", () => { + assert.equal(glmMonthlyRemainingPercentage(0, 250), 100); // no cap, absolute remaining > 100 → clamp full + assert.equal(glmMonthlyRemainingPercentage(1000, 5000), 100); + assert.equal(glmMonthlyRemainingPercentage(0, -5), 0); +}); diff --git a/tests/unit/guardrails-api-3496.test.ts b/tests/unit/guardrails-api-3496.test.ts new file mode 100644 index 0000000000..0a845effae --- /dev/null +++ b/tests/unit/guardrails-api-3496.test.ts @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +// #3496 — docs/reference/API_REFERENCE.md documented a `/api/guardrails*` and +// `/api/shadow*` surface that did not exist (doc-fiction, frozen in the +// check-docs-symbols allowlist). The guardrail pipeline itself is real +// (src/lib/guardrails), so the fix implements the two routes that map to real +// behavior — GET /api/guardrails (list) and POST /api/guardrails/test (dry-run +// the pre-call hooks) — removes the fictional enable/disable/logs + shadow rows +// from the docs, and drops them from KNOWN_STALE_DOC_REFS. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-guardrails-3496-")); +process.env.DATA_DIR = TEST_DATA_DIR; +if (!process.env.JWT_SECRET) process.env.JWT_SECRET = "test-guardrails-3496-jwt-secret"; +if (!process.env.API_KEY_SECRET) process.env.API_KEY_SECRET = "test-guardrails-3496-apikey-secret"; + +const listRoute = await import("../../src/app/api/guardrails/route.ts"); +const testRoute = await import("../../src/app/api/guardrails/test/route.ts"); +const core = await import("../../src/lib/db/core.ts"); + +test.after(() => { + try { + core.getDbInstance().close(); + } catch { + /* ignore */ + } + try { + core.resetDbInstance(); + } catch { + /* ignore */ + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#3496 GET /api/guardrails lists the registered guardrails with status", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/guardrails"); + const res = await listRoute.GET(req); + assert.equal(res.status, 200); + + const body = await res.json(); + assert.ok(Array.isArray(body.guardrails), "expected guardrails[] in the body"); + + const names = body.guardrails.map((g) => g.name); + for (const expected of ["vision-bridge", "pii-masker", "prompt-injection"]) { + assert.ok(names.includes(expected), `expected ${expected} in [${names.join(", ")}]`); + } + + for (const g of body.guardrails) { + assert.equal(typeof g.name, "string"); + assert.equal(typeof g.enabled, "boolean"); + assert.equal(typeof g.priority, "number"); + } +}); + +test("#3496 POST /api/guardrails/test runs the pre-call pipeline over a sample input", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/guardrails/test", { + method: "POST", + body: { input: { messages: [{ role: "user", content: "hello world" }] } }, + }); + const res = await testRoute.POST(req); + assert.equal(res.status, 200); + + const body = await res.json(); + assert.equal(typeof body.blocked, "boolean"); + assert.ok(Array.isArray(body.results), "expected a per-guardrail results[]"); + + const evaluated = body.results.map((r) => r.guardrail); + assert.ok( + evaluated.includes("pii-masker"), + `expected pii-masker to be evaluated, got [${evaluated.join(", ")}]` + ); +}); + +test("#3496 POST /api/guardrails/test honors disabledGuardrails", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/guardrails/test", { + method: "POST", + body: { input: "hello", disabledGuardrails: ["pii-masker"] }, + }); + const res = await testRoute.POST(req); + assert.equal(res.status, 200); + + const body = await res.json(); + const pii = body.results.find((r) => r.guardrail === "pii-masker"); + assert.ok(pii, "pii-masker should still appear in results"); + assert.equal(pii.skipped, true, "pii-masker should be skipped when disabled"); +}); + +test("#3496 POST /api/guardrails/test rejects a body without input (400)", async () => { + const req = await makeManagementSessionRequest("http://localhost/api/guardrails/test", { + method: "POST", + body: {}, + }); + const res = await testRoute.POST(req); + assert.equal(res.status, 400); +}); + +// Regression guard for the quality gate: the docs no longer reference any +// non-existent guardrails/shadow route, and the allowlist no longer freezes them. +test("#3496 check-docs-symbols no longer freezes guardrails/shadow + API_REFERENCE is clean", async () => { + const { KNOWN_STALE_DOC_REFS, collectRouteFiles, extractDocApiPaths, findStaleDocApiRefs } = + await import("../../scripts/check/check-docs-symbols.mjs"); + + // (1) allowlist no longer freezes any guardrails/shadow path + for (const frozen of [...KNOWN_STALE_DOC_REFS]) { + assert.ok( + !frozen.startsWith("/api/guardrails") && !frozen.startsWith("/api/shadow"), + `allowlist should not still freeze ${frozen}` + ); + } + + // (2) API_REFERENCE.md no longer references a non-existent guardrails/shadow route + const routeFiles = collectRouteFiles(); + const apiRefRel = "docs/reference/API_REFERENCE.md"; + const src = fs.readFileSync(path.join(process.cwd(), apiRefRel), "utf8"); + const docPathsByFile = [{ file: apiRefRel, paths: extractDocApiPaths(src) }]; + const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS); + const ghosts = misses.filter( + (m) => m.includes("/api/guardrails") || m.includes("/api/shadow") + ); + assert.deepEqual(ghosts, [], `stale guardrails/shadow refs remain: ${ghosts.join("; ")}`); +}); diff --git a/tests/unit/kiro-import-error-3589.test.ts b/tests/unit/kiro-import-error-3589.test.ts new file mode 100644 index 0000000000..4c789cae32 --- /dev/null +++ b/tests/unit/kiro-import-error-3589.test.ts @@ -0,0 +1,58 @@ +/** + * Regression test for #3589 — Kiro "Import Token" surfaced a bare + * `Internal server error` 500 that hid the real cause. The failure happens while + * validating/refreshing the imported refresh token against AWS (invalid_grant / + * expired token / region mismatch), but the catch returned a generic string, so + * the UI never told the user what was actually wrong. The import error body now + * carries the sanitized upstream cause (Rule #12 — no stack, no secrets), falling + * back to the generic message only when there is nothing to report. + */ +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"; + +// Hermetic temp DATA_DIR so importing the route's dependency graph is safe. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-3589-")); +process.env.DATA_DIR = tmpDir; +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret-3589"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-api-key-secret-3589"; + +const { buildKiroImportError } = await import("../../src/app/api/oauth/kiro/import/route.ts"); + +test.after(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best effort + } +}); + +test("surfaces the real AWS cause (invalid_grant) instead of a generic 500", () => { + const msg = buildKiroImportError(new Error("invalid_grant")); + assert.match(msg, /invalid_grant/); + assert.notEqual(msg, "Internal server error"); +}); + +test("surfaces a region-mismatch cause", () => { + const msg = buildKiroImportError(new Error("Region mismatch: token issued for us-east-1")); + assert.match(msg, /region mismatch/i); +}); + +test("accepts a non-Error throw value", () => { + const msg = buildKiroImportError("expired token"); + assert.match(msg, /expired token/); +}); + +test("never leaks a stack trace in the surfaced message", () => { + const err = new Error("boom"); + err.stack = "Error: boom\n at /home/user/app/src/lib/oauth/services/kiro.ts:120:11"; + const msg = buildKiroImportError(err); + assert.doesNotMatch(msg, /at \//); +}); + +test("falls back to the generic message when there is nothing to report", () => { + assert.equal(buildKiroImportError(new Error("")), "Internal server error"); + assert.equal(buildKiroImportError(undefined), "Internal server error"); +}); diff --git a/tests/unit/managed-model-import.test.ts b/tests/unit/managed-model-import.test.ts index a0e9ea5c22..5df2d935a3 100644 --- a/tests/unit/managed-model-import.test.ts +++ b/tests/unit/managed-model-import.test.ts @@ -91,7 +91,7 @@ test("pruning stale connection available models during import", async () => { db.prepare( "INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ).run("conn-active", "openrouter", "apikey", "Active Connection", 1, "2026-05-29", "2026-05-29"); - + db.prepare( "INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ).run("conn-stale", "openrouter", "apikey", "Stale Connection", 0, "2026-05-29", "2026-05-29"); @@ -114,7 +114,7 @@ test("pruning stale connection available models during import", async () => { // Check models for "openrouter" const allSyncedModels = await modelsDb.getSyncedAvailableModels("openrouter"); - + // Stale connection should be pruned. Active connection and the new syncing connection should be kept. const ids = allSyncedModels.map((m) => m.id); assert.ok(ids.includes("shared/model-active")); @@ -149,11 +149,7 @@ test("antigravity sync dynamically builds and saves mitmAlias mappings", async ( assert.equal(mitmMappings["gemini-3.5-flash"], "antigravity/gemini-3.5-flash"); assert.equal(mitmMappings["custom-antigravity-model"], "antigravity/custom-antigravity-model"); - // Should contain reverse alias mappings (gemini-3.5-flash-preview maps to gemini-3.5-flash) - assert.equal(mitmMappings["gemini-3.5-flash-preview"], "antigravity/gemini-3.5-flash"); - assert.equal(mitmMappings["gemini-3-flash-agent"], "antigravity/gemini-3.5-flash"); - - // Should contain forward alias mappings (gemini-3.5-flash-preview maps to gemini-3.5-flash) - assert.equal(mitmMappings["gemini-3.5-flash-preview"], "antigravity/gemini-3.5-flash"); + // Removed Antigravity 2.0 preview/agent aliases must not be reintroduced. + assert.equal(mitmMappings["gemini-3.5-flash-preview"], undefined); + assert.equal(mitmMappings["gemini-3-flash-agent"], undefined); }); - diff --git a/tests/unit/mcp-published-files-closure-3578.test.ts b/tests/unit/mcp-published-files-closure-3578.test.ts new file mode 100644 index 0000000000..84d342a40e --- /dev/null +++ b/tests/unit/mcp-published-files-closure-3578.test.ts @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +// #3578 — `omniroute --mcp` crashed on npm installs with ERR_MODULE_NOT_FOUND for +// src/lib/combos/steps.ts: the MCP server runs from raw TypeScript source and imports +// across src/ + open-sse/, but the published `files` allowlist only shipped a few +// cherry-picked paths. This gate computes the MCP server's transitive import closure +// and asserts every reachable src/ + open-sse/ file is covered by a package.json +// `files` entry, so a missing dir can never silently ship a broken --mcp again. + +const ROOT = process.cwd(); + +function resolveImport(fromFile: string, spec: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = path.join("src", spec.slice(2)); + else if (spec.startsWith("@omniroute/open-sse/")) + base = path.join("open-sse", spec.slice("@omniroute/open-sse/".length)); + else if (spec === "@omniroute/open-sse") base = path.join("open-sse", "index"); + else if (spec.startsWith("./") || spec.startsWith("../")) + base = path.join(path.dirname(fromFile), spec); + else return null; // bare package — not our source + base = base.replace(/\.(ts|tsx|js|mjs)$/, ""); + const cands = [ + base + ".ts", + base + ".tsx", + path.join(base, "index.ts"), + path.join(base, "index.tsx"), + base + ".js", + base + ".mjs", + ]; + for (const c of cands) if (fs.existsSync(path.join(ROOT, c))) return c; + return null; +} + +function computeMcpClosure(): string[] { + const roots: string[] = []; + for (const f of fs.readdirSync(path.join(ROOT, "open-sse/mcp-server"))) { + if (f.endsWith(".ts")) roots.push("open-sse/mcp-server/" + f); + } + for (const d of ["open-sse/mcp-server/tools", "open-sse/mcp-server/schemas"]) { + const abs = path.join(ROOT, d); + if (fs.existsSync(abs)) + for (const f of fs.readdirSync(abs)) if (f.endsWith(".ts")) roots.push(d + "/" + f); + } + + const seen = new Set(); + const stack = [...roots]; + const importRe = + /(?:import|export)[^"']*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g; + while (stack.length) { + const f = stack.pop() as string; + if (seen.has(f)) continue; + seen.add(f); + let src: string; + try { + src = fs.readFileSync(path.join(ROOT, f), "utf8"); + } catch { + continue; + } + let m: RegExpExecArray | null; + while ((m = importRe.exec(src))) { + const spec = m[1] || m[2]; + if (!spec) continue; + const r = resolveImport(f, spec); + if (r && !seen.has(r)) stack.push(r); + } + } + return [...seen].filter((f) => f.startsWith("src/") || f.startsWith("open-sse/")); +} + +function isCoveredByFiles(file: string, filesEntries: string[]): boolean { + for (const entry of filesEntries) { + if (entry.endsWith("/")) { + if (file === entry.slice(0, -1) || file.startsWith(entry)) return true; + } else if (file === entry || file.startsWith(entry + "/")) { + return true; + } + } + return false; +} + +test("#3578 every MCP-server source file is covered by package.json files", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); + const filesEntries: string[] = pkg.files || []; + const closure = computeMcpClosure(); + + // Sanity: the closure must actually include the file the bug report hit. + assert.ok( + closure.includes("src/lib/combos/steps.ts"), + "closure should include the file from the bug report (#3578)" + ); + + const uncovered = closure.filter((f) => !isCoveredByFiles(f, filesEntries)); + assert.deepEqual( + uncovered, + [], + `These MCP-reachable source files are not in package.json "files" and would 404 a published --mcp:\n` + + uncovered.map((f) => " - " + f).join("\n") + ); +}); + +// #3821-review (LEDGER-1): the static `files` check above only guards UNDER-inclusion +// (every MCP file is allowlisted). It cannot see that the whole-directory entries +// (open-sse/, src/lib/, ...) also drag co-located test files into the tarball, nor that +// a future secret-bearing fixture under a shipped dir would publish. This test asserts +// the REAL `npm pack --dry-run` output in BOTH directions: the MCP closure is present AND +// no `__tests__` / `*.test.*` / `*.spec.*` file ships. It is the regression anchor for the +// `!**/*.test.*` negations in package.json `files`. +function packedFilePaths(): string[] { + // --dry-run writes no tarball; --json emits [{ files: [{ path }] }] on stdout. + const out = execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + const parsed = JSON.parse(out) as Array<{ files?: Array<{ path: string }> }>; + const entry = parsed[0]; + assert.ok(entry?.files?.length, "npm pack --dry-run returned no files"); + return entry.files!.map((f) => f.path); +} + +const TEST_FILE_RE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/; + +test("#3578/#3821 npm pack ships the MCP closure but no test files", () => { + const packed = packedFilePaths(); + const packedSet = new Set(packed); + + // Direction 1 — under-inclusion: every MCP-reachable source file is actually packed. + const closure = computeMcpClosure(); + const missing = closure.filter((f) => !packedSet.has(f)); + assert.deepEqual( + missing, + [], + `MCP-reachable source files are missing from the published tarball (would 404 --mcp):\n` + + missing.map((f) => " - " + f).join("\n") + ); + // Spot-check the file from the original bug report. + assert.ok( + packedSet.has("src/lib/combos/steps.ts"), + "src/lib/combos/steps.ts (the #3578 bug file) must be in the tarball" + ); + + // Direction 2 — over-inclusion: no co-located test / spec file is published. + const shippedTests = packed.filter((f) => TEST_FILE_RE.test(f)); + assert.deepEqual( + shippedTests, + [], + `These test files leaked into the npm tarball — tighten package.json "files" negations:\n` + + shippedTests.map((f) => " - " + f).join("\n") + ); +}); diff --git a/tests/unit/mitm-startup-error-3606.test.ts b/tests/unit/mitm-startup-error-3606.test.ts new file mode 100644 index 0000000000..e418477c5d --- /dev/null +++ b/tests/unit/mitm-startup-error-3606.test.ts @@ -0,0 +1,49 @@ +/** + * Regression test for #3606 — MITM startup failure message is misleading. + * + * `startMitm()` used to always throw "port 443 may be in use" regardless of the + * real cause, because the stderr parser only matched EADDRINUSE. `server.cjs` + * already distinguishes EADDRINUSE / EACCES / missing-ROUTER_API_KEY / other on + * stderr (each prefixed with "❌"). `interpretMitmStartupError()` now maps the + * captured stderr to the actual cause so the user is not sent debugging port 443 + * when the real problem is a missing API key or a permission error. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { interpretMitmStartupError } from "../../src/mitm/manager.ts"; + +test("EADDRINUSE stderr still reports the port-in-use cause", () => { + const msg = interpretMitmStartupError("❌ Port 443 already in use", 443); + assert.match(msg, /443/); + assert.match(msg, /in use/i); +}); + +test("EACCES stderr reports a permission cause, not port-in-use", () => { + const msg = interpretMitmStartupError("❌ Permission denied for port 443", 443); + assert.match(msg, /permission/i); + assert.doesNotMatch(msg, /in use/i); +}); + +test("missing ROUTER_API_KEY stderr reports the API-key cause, not port-in-use", () => { + const msg = interpretMitmStartupError("❌ ROUTER_API_KEY required", 443); + assert.match(msg, /ROUTER_API_KEY|API key/i); + assert.doesNotMatch(msg, /in use/i); +}); + +test("an arbitrary ❌ error line is surfaced verbatim (without the marker)", () => { + const msg = interpretMitmStartupError("some log\n❌ ENOENT: server.cjs missing\nmore log", 8443); + assert.match(msg, /ENOENT: server\.cjs missing/); + assert.doesNotMatch(msg, /❌/); +}); + +test("respects a non-default port in the port-in-use message", () => { + const msg = interpretMitmStartupError("❌ Port 8443 already in use", 8443); + assert.match(msg, /8443/); +}); + +test("with no captured stderr, falls back to a generic (non-misleading) message", () => { + const msg = interpretMitmStartupError("", 443); + // Must NOT assert it is specifically a port-443 problem when nothing was captured. + assert.doesNotMatch(msg, /port 443 may be in use/i); + assert.match(msg, /failed to start/i); +}); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 14d88393ca..34ee03ce00 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -690,11 +690,13 @@ test("v1 models catalog exposes Antigravity client-visible preview aliases inste assert.equal(response.status, 200); assert.ok(ids.has("antigravity/gemini-3-pro-preview")); - assert.ok(ids.has("antigravity/gemini-3-flash-preview")); - // #3184/#3303: the Gemini budget tiers (`-high`/`-low`) are user-callable - // client-visible aliases on the Antigravity OAuth backend (agy parity), so - // they ARE now exposed in the catalog. (They alias to the plain - // `gemini-3.1-pro` upstream id — see ANTIGRAVITY_MODEL_ALIASES.) + assert.ok(ids.has("antigravity/gemini-3.5-flash-low")); + assert.ok(ids.has("antigravity/gemini-3.5-flash-medium")); + assert.ok(ids.has("antigravity/gemini-3.5-flash-high")); + assert.equal(ids.has("antigravity/gemini-3-flash-preview"), false); + assert.equal(ids.has("antigravity/gemini-3-flash-agent"), false); + // Gemini 3.1 Pro budget tiers remain client-visible aliases for the plain + // `gemini-3.1-pro` upstream id — see ANTIGRAVITY_MODEL_ALIASES. assert.ok(ids.has("antigravity/gemini-3.1-pro-high")); // The legacy `gemini-claude-*` ids are alias KEYS (remapped to live upstream // ids), not public catalog entries, so they stay unexposed. diff --git a/tests/unit/provider-columns.test.ts b/tests/unit/provider-columns.test.ts index eed1c56d09..4034cd3424 100644 --- a/tests/unit/provider-columns.test.ts +++ b/tests/unit/provider-columns.test.ts @@ -68,14 +68,15 @@ test("getProviderColumns: Antigravity falls back to dynamic schema (first 3 quot "claude-opus-4-6-thinking": { used: 0, total: 100, remainingPercentage: 100 }, "claude-sonnet-4-6": { used: 0, total: 100, remainingPercentage: 100 }, "gemini-3.1-pro-low": { used: 0, total: 100, remainingPercentage: 100 }, - "gemini-3-flash-agent": { used: 0, total: 100, remainingPercentage: 100 }, "gemini-3.5-flash-low": { used: 0, total: 100, remainingPercentage: 100 }, + "gemini-3.5-flash-medium": { used: 0, total: 100, remainingPercentage: 100 }, + "gemini-3.5-flash-high": { used: 0, total: 100, remainingPercentage: 100 }, }, }); const schema = providerColumns.getProviderColumns("antigravity", quotas); assert.equal(schema.columns.length, providerColumns.MAX_DYNAMIC_COLUMNS); - assert.equal(schema.overflowCount, 2, "5 quotas - 3 visible = 2 overflow"); + assert.equal(schema.overflowCount, 3, "6 quotas - 3 visible = 3 overflow"); }); test("getProviderColumns: credits never become columns, always counted toward overflow", () => { @@ -105,7 +106,6 @@ test("getProviderColumns: unknown provider uses dynamic fallback", () => { }); test("getProviderColumns: tolerates non-array quotas", () => { - // @ts-expect-error — exercise the runtime guard const schema = providerColumns.getProviderColumns("codex", null); assert.equal(schema.columns.length, 2); assert.equal(schema.columns[0].quota, null); diff --git a/tests/unit/provider-limits-sanitize-scope-3821.test.ts b/tests/unit/provider-limits-sanitize-scope-3821.test.ts new file mode 100644 index 0000000000..60c29f0098 --- /dev/null +++ b/tests/unit/provider-limits-sanitize-scope-3821.test.ts @@ -0,0 +1,107 @@ +/** + * LEDGER-2 (#3821-review) — getSanitizedCachedProviderLimitsMap is polled by the + * ProviderLimits dashboard on an auto-refresh interval. It used to run an + * unconditional `SELECT * FROM provider_connections` (decrypting every active + * connection's credentials) on every poll, even though quota-key sanitization only + * ever rewrites Antigravity/agy entries. The fix scopes the connection scan to + * antigravity/agy (and skips it entirely for an empty cache). + * + * These tests pin the BEHAVIOR the optimization must preserve: + * 1. empty cache → {} (no scan needed) + * 2. non-Antigravity entry → returned verbatim (a junk quota key survives), proving + * entries whose connection is no longer fetched are still passed through unchanged + * 3. Antigravity entry → still sanitized (a non-user-callable quota key is dropped), + * proving the scoped query still feeds the sanitizer + * + * (2) is the load-bearing case: with the old code the openai connection was fetched and + * present in the lookup; with the new code it is NOT fetched at all, yet the output must + * be identical — which it is, because sanitizeProviderLimitsCacheForConnection returns + * the entry unchanged when no matching connection is supplied. + */ +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-plimits-scope-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-plimits-scope-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerLimitsDb = await import("../../src/lib/db/providerLimits.ts"); +const providerLimits = await import("../../src/lib/usage/providerLimits.ts"); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function cacheEntry(quotas: Record) { + return { + quotas, + plan: null, + message: null, + fetchedAt: new Date(0).toISOString(), + source: null, + }; +} + +test("empty provider-limits cache returns {} without any connection scan", async () => { + const out = await providerLimits.getSanitizedCachedProviderLimitsMap(); + assert.deepEqual(out, {}); +}); + +test("non-Antigravity cache entry is returned verbatim (junk quota key survives)", async () => { + // An active openai connection whose credentials would be decrypted by the old + // unconditional scan. Under the fix it is never fetched — the entry must still pass + // through unchanged. + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "api_key", + name: "OpenAI key", + apiKey: "sk-test-openai", + }); + const quotas = { "definitely-not-a-real-model": { used: 1, limit: 10 } }; + providerLimitsDb.setProviderLimitsCache((conn as { id: string }).id, cacheEntry(quotas)); + + const out = await providerLimits.getSanitizedCachedProviderLimitsMap(); + const entry = out[(conn as { id: string }).id]; + assert.ok(entry, "openai cache entry should be present"); + // Sanitization is antigravity/agy-only → the junk key is NOT dropped for openai. + assert.deepEqual(entry.quotas, quotas); +}); + +test("Antigravity cache entry is still sanitized (non-user-callable quota key dropped)", async () => { + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "Antigravity acct", + email: "antigravity@example.test", + accessToken: "ag-access", + refreshToken: "ag-refresh", + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }); + // `credits` is always allowed; the junk model id is not user-callable → dropped. + const quotas = { + credits: { used: 5, limit: 100 }, + "definitely-not-a-real-model": { used: 1, limit: 10 }, + }; + providerLimitsDb.setProviderLimitsCache((conn as { id: string }).id, cacheEntry(quotas)); + + const out = await providerLimits.getSanitizedCachedProviderLimitsMap(); + const entry = out[(conn as { id: string }).id]; + assert.ok(entry?.quotas, "antigravity cache entry should be present"); + assert.ok("credits" in (entry.quotas as Record), "credits is kept"); + assert.ok( + !("definitely-not-a-real-model" in (entry.quotas as Record)), + "non-user-callable quota key is dropped for antigravity" + ); +}); diff --git a/tests/unit/public-client-ids-3493.test.ts b/tests/unit/public-client-ids-3493.test.ts new file mode 100644 index 0000000000..5141f6abb6 --- /dev/null +++ b/tests/unit/public-client-ids-3493.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; + +// #3493 — five public OAuth client_ids were migrated from string literals to +// resolvePublicCred() (Hard Rule #11). These assertions guard that the embedded +// masked-byte defaults still decode to the exact public client_ids, so the OAuth +// flows are byte-for-byte unchanged, and that env overrides still win. + +const EXPECTED_CLIENT_IDS: Record = { + claude_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + codex_id: "app_EMoamEEZ73f0CkXaXp7hrann", + qwen_id: "f0304373b74a44d2b584a3fb70ca9e56", + kimi_id: "17e5f671-d194-4dfb-9706-5516cb48c098", + github_copilot_id: "Iv1.b507a08c87ecfe98", +}; + +test("#3493 embedded public client_ids decode to their original literals", () => { + for (const [key, expected] of Object.entries(EXPECTED_CLIENT_IDS)) { + assert.equal( + resolvePublicCred(key as never), + expected, + `${key} must decode to its public client_id (OAuth flow unchanged)` + ); + } +}); + +test("#3493 env override takes priority over the embedded default", () => { + const prev = process.env.CLAUDE_OAUTH_CLIENT_ID; + process.env.CLAUDE_OAUTH_CLIENT_ID = "custom-override-id"; + try { + assert.equal( + resolvePublicCred("claude_id" as never, "CLAUDE_OAUTH_CLIENT_ID"), + "custom-override-id" + ); + } finally { + if (prev === undefined) delete process.env.CLAUDE_OAUTH_CLIENT_ID; + else process.env.CLAUDE_OAUTH_CLIENT_ID = prev; + } +}); + +test("#3493 the migrated OAuth/registry configs resolve to the expected client_ids", async () => { + const { CLAUDE_CONFIG, CODEX_CONFIG, QWEN_CONFIG } = await import( + "../../src/lib/oauth/constants/oauth.ts" + ); + // Only assert when env doesn't override (CI/dev may set these); skip the assert + // for any that are env-overridden so the test stays deterministic. + if (!process.env.CLAUDE_OAUTH_CLIENT_ID) { + assert.equal(CLAUDE_CONFIG.clientId, EXPECTED_CLIENT_IDS.claude_id); + } + if (!process.env.CODEX_OAUTH_CLIENT_ID) { + assert.equal(CODEX_CONFIG.clientId, EXPECTED_CLIENT_IDS.codex_id); + } + if (!process.env.QWEN_OAUTH_CLIENT_ID) { + assert.equal(QWEN_CONFIG.clientId, EXPECTED_CLIENT_IDS.qwen_id); + } +}); diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 5c716aebd5..49b89d5499 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -17,6 +17,28 @@ test("extractThinkingFromContent separates think blocks from visible content", ( assert.equal(parsed.thinking, "reasoning 1\n\nreasoning 2"); }); +// #3821-review LEDGER-7 — the unclosed-reasoning-tag heuristic (#3605) reclassifies a +// dangling `` is NOT captured. +test("extractThinkingFromContent preserves a real prefix before a dangling reasoning tag", () => { + const parsed = extractThinkingFromContent("Here is the answer. { + const parsed = extractThinkingFromContent("§54§ as a reasoning tag", () => { + const parsed = extractThinkingFromContent("See the approach here"); + assert.equal(parsed.content, "See the approach here"); + assert.equal(parsed.thinking, null); +}); + test("sanitizeOpenAIResponse strips non-standard fields and preserves required top-level fields", () => { const sanitized = sanitizeOpenAIResponse({ id: "chatcmpl_existing", @@ -63,6 +85,23 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, preserves re assert.deepEqual((sanitized as any).choices[0].message.function_call, { name: "legacy" }); }); +test("sanitizeOpenAIResponse extracts unclosed reasoning wrappers into reasoning_content", () => { + const sanitized = sanitizeOpenAIResponse({ + model: "gpt-4.1", + choices: [ + { + message: { + role: "assistant", + content: "§54§ { const sanitized = sanitizeOpenAIResponse({ model: "gpt-4.1", diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index de6d68655a..2dfa9d4089 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -24,18 +24,19 @@ test("T28: gemini AI Studio catalog includes current preview models", () => { assert.ok(geminiCliIds.includes("gemini-3-flash-preview")); }); -test("T28: antigravity static catalog exposes client-visible Gemini preview IDs", () => { +test("T28: antigravity static catalog exposes client-visible Gemini tier IDs", () => { const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); assert.ok(staticIds.includes("gemini-3-pro-preview")); - assert.ok(staticIds.includes("gemini-3-flash-preview")); - // #3303 (agy parity, discussion #3184): the Gemini budget tiers ARE - // client-visible on the Antigravity OAuth backend — restoring tiers an - // earlier assumption had wrongly hidden. + assert.ok(staticIds.includes("gemini-3.5-flash-low")); + assert.ok(staticIds.includes("gemini-3.5-flash-medium")); + assert.ok(staticIds.includes("gemini-3.5-flash-high")); assert.ok(staticIds.includes("gemini-3.1-pro-low")); assert.ok(staticIds.includes("gemini-3.1-pro-high")); // Legacy aliases that were never client-visible stay absent. assert.ok(!staticIds.includes("gemini-3-pro-high")); + assert.ok(!staticIds.includes("gemini-3-flash-preview")); + assert.ok(!staticIds.includes("gemini-3-flash-agent")); assert.ok(!staticIds.includes("gemini-claude-sonnet-4-5")); assert.ok(!staticIds.includes("gemini-claude-sonnet-4-5-thinking")); assert.ok(!staticIds.includes("gemini-claude-opus-4-5-thinking")); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 8398903ce5..108eb8a62b 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -381,6 +381,118 @@ test("Gemini stream: converts textual Tool call block to structured tool_calls", assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls"); }); +test("Gemini stream: routes textual reasoning tags to reasoning_content before tool calls", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-textual-thought-tool", + modelVersion: "gemini-3.5-flash-high", + candidates: [ + { + content: { + parts: [ + { + text: "§54§ event.choices?.[0]?.delta?.content?.includes(" event.choices?.[0]?.delta?.reasoning_content)?.choices[0].delta + .reasoning_content, + "Need to inspect first." + ); + const toolCall = result.find((event: any) => event.choices?.[0]?.delta?.tool_calls)?.choices[0] + .delta.tool_calls[0]; + assert.equal(toolCall.id, "call_grep"); + assert.equal(result.at(-1).choices[0].finish_reason, "tool_calls"); +}); + +test("Gemini stream: keeps textual reasoning hidden across split chunks", () => { + const state = createStreamingState(); + + const first = geminiToOpenAIResponse( + { + responseId: "resp-split-thought", + modelVersion: "gemini-3.5-flash-high", + candidates: [{ content: { parts: [{ text: "§54§ event.choices?.[0]?.delta?.content), + false + ); + + const second = geminiToOpenAIResponse( + { + responseId: "resp-split-thought", + modelVersion: "gemini-3.5-flash-high", + candidates: [{ content: { parts: [{ text: "ught\nNeed to inspect" }] } }], + }, + state + ); + assert.equal( + (second ?? []).some((event: any) => + event.choices?.[0]?.delta?.content?.includes("Need to inspect") + ), + false + ); + + const third = geminiToOpenAIResponse( + { + responseId: "resp-split-thought", + modelVersion: "gemini-3.5-flash-high", + candidates: [{ content: { parts: [{ text: " more event.choices?.[0]?.delta?.content?.includes("more")), + false + ); + + const fourth = geminiToOpenAIResponse( + { + responseId: "resp-split-thought", + modelVersion: "gemini-3.5-flash-high", + candidates: [{ content: { parts: [{ text: "ught>Visible answer" }] } }], + }, + state + ); + assert.equal( + fourth.some( + (event: any) => event.choices?.[0]?.delta?.reasoning_content === "Need to inspect more" + ), + true + ); + assert.equal( + fourth.some((event: any) => event.choices?.[0]?.delta?.content?.includes("ught>")), + false + ); + assert.equal( + fourth.find((event: any) => event.choices?.[0]?.delta?.content)?.choices[0].delta.content, + "Visible answer" + ); +}); + test("Gemini stream: converts prefixed textual Tool call block with zero-width chars", () => { const state = createStreamingState(); const result = geminiToOpenAIResponse( @@ -826,7 +938,7 @@ test("Gemini stream: index mismatch regression test with zero-width characters i content: { parts: [ { - text: "\u200BКак исправить: [Tool call: terminal]\nArguments: {\"command\":\"whoami\"}", + text: '\u200BКак исправить: [Tool call: terminal]\nArguments: {"command":"whoami"}', }, ], }, @@ -837,7 +949,9 @@ test("Gemini stream: index mismatch regression test with zero-width characters i state ); - const leakedContent = result.map((event: any) => event.choices?.[0]?.delta?.content || "").join(""); + const leakedContent = result + .map((event: any) => event.choices?.[0]?.delta?.content || "") + .join(""); assert.equal(leakedContent, "Как исправить: "); const toolCalls = result.flatMap((event: any) => event.choices?.[0]?.delta?.tool_calls || []); @@ -870,7 +984,7 @@ test("Gemini stream: partial tool call with (empty) prefix check at chunk end do content: { parts: [ { - text: "ll: terminal]\nArguments: {\"command\":\"whoami\"}", + text: 'll: terminal]\nArguments: {"command":"whoami"}', }, ], }, @@ -942,7 +1056,7 @@ test("Gemini stream: parses textual tool call that starts in a subsequent chunk test("Gemini stream: checks lastParen before lastBracket when identifying partial (empty) markers with distinct chuncks", () => { const state = createStreamingState() as any; - + // Имитируем чанк, который кончается на частичный "(empty)[Tool call:" маркер, например "(em" const chunk1 = { responseId: "resp-test-empty-partial", @@ -982,7 +1096,7 @@ test("Gemini stream: checks lastParen before lastBracket when identifying partia content: { parts: [ { - text: ' call: my_tool]\nArguments: {}', + text: " call: my_tool]\nArguments: {}", }, ], }, @@ -1009,5 +1123,111 @@ test("Gemini stream: checks lastParen before lastBracket when identifying partia assert.equal(toolCall.function.arguments, "{}"); }); +// #3821-review LEDGER-4 — a signed native functionCall arriving while a textual +// `` wrapper opened in an earlier chunk is still buffered must flush that +// buffered reasoning as reasoning_content, not silently discard it. +test("Gemini stream: open textual reasoning is flushed before a signed native tool call", () => { + const state = createStreamingState(); + // chunk 1: opens a wrapper with no close tag → buffered, nothing emitted. + const r1 = + geminiToOpenAIResponse( + { + responseId: "resp-flush-reasoning", + modelVersion: "gemini-3-flash-agent", + candidates: [{ content: { parts: [{ text: "deep reasoning here" }] } }], + }, + state + ) || []; + assert.ok( + !r1.some((e: any) => e.choices?.[0]?.delta?.reasoning_content), + "reasoning is still buffered (awaiting close tag) — nothing emitted yet" + ); + // chunk 2: signed native functionCall while the reasoning wrapper is still open. + const r2 = + geminiToOpenAIResponse( + { + responseId: "resp-flush-reasoning", + modelVersion: "gemini-3-flash-agent", + candidates: [ + { + content: { + parts: [ + { + thoughtSignature: "sig-flush-1", + functionCall: { id: "call-flush-1", name: "do_thing", args: {} }, + }, + ], + }, + }, + ], + }, + state + ) || []; + + const reasoningIdx = r2.findIndex((e: any) => e.choices?.[0]?.delta?.reasoning_content); + const toolIdx = r2.findIndex((e: any) => e.choices?.[0]?.delta?.tool_calls); + assert.equal( + r2[reasoningIdx]?.choices[0].delta.reasoning_content, + "deep reasoning here", + "buffered textual reasoning must be flushed, not dropped, when a tool call arrives" + ); + assert.equal(r2[toolIdx]?.choices[0].delta.tool_calls[0].id, "call-flush-1"); + assert.ok(reasoningIdx >= 0 && toolIdx > reasoningIdx, "reasoning is emitted before the tool call"); +}); + +// #3821-review LEDGER-15 — a reasoning-only chunk interrupting a partially-buffered +// textual "[Tool call: ...]" must not strand the buffer; it resolves once the rest of +// the tool-call text arrives (or at finishReason). +test("Gemini stream: partial textual tool call survives a reasoning-only chunk", () => { + const state = createStreamingState(); + + // chunk 1: partial textual tool call (incomplete JSON) → buffered. + geminiToOpenAIResponse( + { + responseId: "resp-interleave", + modelVersion: "gemini-3.5-flash-low", + candidates: [ + { content: { parts: [{ text: '[Tool call: terminal]\nArguments: {"command":"ls' }] } }, + ], + }, + state + ); + + // chunk 2: a reasoning-only chunk fully consumed as reasoning_content. + const r2 = + geminiToOpenAIResponse( + { + responseId: "resp-interleave", + modelVersion: "gemini-3.5-flash-low", + candidates: [{ content: { parts: [{ text: "pondering" }] } }], + }, + state + ) || []; + assert.equal( + r2.find((e: any) => e.choices?.[0]?.delta?.reasoning_content)?.choices[0].delta + .reasoning_content, + "pondering" + ); + assert.ok( + typeof state.textualToolCallBuffer === "string" && + state.textualToolCallBuffer.includes("[Tool call: terminal]"), + "the partial tool-call buffer must survive the reasoning-only chunk" + ); + + // chunk 3: completes the tool-call text + finishReason → resolves to a structured call. + const r3 = + geminiToOpenAIResponse( + { + responseId: "resp-interleave", + modelVersion: "gemini-3.5-flash-low", + candidates: [{ content: { parts: [{ text: '"}' }] }, finishReason: "STOP" }], + }, + state + ) || []; + const toolCall = r3.find((e: any) => e.choices?.[0]?.delta?.tool_calls)?.choices[0].delta + .tool_calls[0]; + assert.ok(toolCall, "the textual tool call resolves after the reasoning-only interruption"); + assert.equal(toolCall.function.name, "terminal"); +}); diff --git a/tests/unit/upstream-ca-test-route-3488.test.ts b/tests/unit/upstream-ca-test-route-3488.test.ts new file mode 100644 index 0000000000..750345534e --- /dev/null +++ b/tests/unit/upstream-ca-test-route-3488.test.ts @@ -0,0 +1,116 @@ +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"; + +// Point the data dir at a throwaway location BEFORE importing the route so we can assert +// the validate-only route never writes the persisted CA-path file. resolveMitmDataDir() +// reads DATA_DIR at call time, so this also governs the route under test. +const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ca-datadir-")); +process.env.DATA_DIR = DATA_DIR; +// The persisted path used by the real (persisting) POST /upstream-ca route. +const PERSISTED_CA_PATH_FILE = path.join(DATA_DIR, "mitm", "upstream-ca.path"); + +const { POST } = await import("../../src/app/api/tools/agent-bridge/upstream-ca/test/route.ts"); + +// #3488 — UpstreamCaField's "Test" button POSTed to /api/tools/agent-bridge/upstream-ca/test, +// which did not exist (404). The new validate-only route checks the CA file exists and is a +// parseable PEM certificate WITHOUT persisting/activating it. + +// A throwaway self-signed cert (CN=OmniRoute Test CA), valid to 2036. +const TEST_CA_PEM = `-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIUISgNKO/v/z0FdUIPoCD4dwgKbacwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRT21uaVJvdXRlIFRlc3QgQ0EwHhcNMjYwNjEwMjExNDMx +WhcNMzYwNjA3MjExNDMxWjAcMRowGAYDVQQDDBFPbW5pUm91dGUgVGVzdCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALkubKCA7sgOph0nsKhQZNoH +UaQo+mrodWJ+23yVnxPygBQQay6okO1w5U6yxweinyCC0jB87Y386q30cqYK6NCf +HbAgkNRelhxeoU71DztIjIaKZCTlra5CCjVxVzvIOu4PoP8UgoLy/jOLM15XiM5B +entgjw62qXGRGak2Thiac+dHRzKJAIPxRDnWDrgQFsduNtSb1sGbivjUjLLEabm0 +gokYlJpNCYJvS31qvL37aeV8igjt8hsReVEb5qm5RiiAepM9B3gvKvn0fsKvxT2a +rmqgySF+o2aTi+PW3+ZySoWoUL6b7GSA/CpF6Mc3u2qM/DvU4Kr0K8Y5EE+PsYUC +AwEAAaNTMFEwHQYDVR0OBBYEFD/qt9vsjOHNvlfT6Z4j9myR4GJJMB8GA1UdIwQY +MBaAFD/qt9vsjOHNvlfT6Z4j9myR4GJJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAH0WP40mF66cqUxQjamHS2BScRkn5E+SvwoZD12ZvQO/kgj/ +wbWu5mZOf5cpuqHrOmfOC+6itIkZb7v6d0CRM19xcoAg1mVWFFSk0iroFCw0qltN +kB5WPvKHI6JRkr7HdUTD1qW9ljveMPfZ/Fm9ZM6QhCOLifzNTP2GMTVyIMAvig6B +TgmL/sn4dw4C2UTMQioMMXHSeJ90OD4Pv3mqX16JKrRSICoTExBoEIF23kWWJL6m +RL5Jiv1pdbFujHL8l9KPI2xmsWtkKutxOL2O5zpdUxP4noNVInDqEmbriK6CKY4y +hWHoQhtd4zf9H6+NIi38SPTCAmCjgU7iVq6mWoE= +-----END CERTIFICATE----- +`; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ca-test-")); +const validCaPath = path.join(dir, "valid-ca.pem"); +const nonPemPath = path.join(dir, "not-a-cert.txt"); +fs.writeFileSync(validCaPath, TEST_CA_PEM); +fs.writeFileSync(nonPemPath, "this is not a certificate"); + +test.after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(DATA_DIR, { recursive: true, force: true }); +}); + +function postJson(body: unknown): Request { + return new Request("http://localhost/api/tools/agent-bridge/upstream-ca/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("#3488 valid PEM cert → 200 ok with subject", async () => { + const res = await POST(postJson({ path: validCaPath })); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + assert.match(json.subject, /OmniRoute Test CA/); +}); + +test("#3488 does NOT persist the CA path (validate-only)", async () => { + // Real side-effect guard (#3821-review LEDGER-11): the persisting POST /upstream-ca + // route writes /mitm/upstream-ca.path. After a successful /test call that file + // must NOT exist — proving the dry-run never persisted/activated the CA. + assert.ok( + !fs.existsSync(PERSISTED_CA_PATH_FILE), + "precondition: persisted CA-path file should not exist before the test" + ); + + const res = await POST(postJson({ path: validCaPath })); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + + assert.ok( + !fs.existsSync(PERSISTED_CA_PATH_FILE), + "validate-only /test route must not write the persisted upstream-ca.path file" + ); + // And it must not advertise activation/persistence in its response shape. + assert.equal(json.persisted, undefined); + assert.equal(json.activated, undefined); +}); + +test("#3488 non-existent path → 400", async () => { + const res = await POST(postJson({ path: path.join(dir, "nope.pem") })); + assert.equal(res.status, 400); +}); + +test("#3488 file that is not a PEM cert → 400", async () => { + const res = await POST(postJson({ path: nonPemPath })); + assert.equal(res.status, 400); +}); + +test("#3488 invalid body (missing path) → 400", async () => { + const res = await POST(postJson({})); + assert.equal(res.status, 400); +}); + +test("#3488 invalid JSON body → 400", async () => { + const req = new Request("http://localhost/api/tools/agent-bridge/upstream-ca/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + const res = await POST(req); + assert.equal(res.status, 400); +}); diff --git a/tests/unit/usage-events.test.ts b/tests/unit/usage-events.test.ts new file mode 100644 index 0000000000..554fa9cfde --- /dev/null +++ b/tests/unit/usage-events.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { emitUsageRecorded, onUsageRecorded } from "../../src/lib/usage/usageEvents.ts"; + +test("emitUsageRecorded delivers provider + connectionId to subscribers", () => { + const seen: Array<[string, string]> = []; + const off = onUsageRecorded((provider, connectionId) => seen.push([provider, connectionId])); + try { + emitUsageRecorded("antigravity", "conn-1"); + assert.deepEqual(seen, [["antigravity", "conn-1"]]); + } finally { + off(); + } +}); + +test("emitUsageRecorded no-ops when provider or connectionId is missing", () => { + let calls = 0; + const off = onUsageRecorded(() => { + calls += 1; + }); + try { + emitUsageRecorded(null, "conn-1"); + emitUsageRecorded("agy", ""); + emitUsageRecorded(undefined, undefined); + assert.equal(calls, 0); + } finally { + off(); + } +}); + +test("onUsageRecorded unsubscribe stops further delivery", () => { + let calls = 0; + const off = onUsageRecorded(() => { + calls += 1; + }); + emitUsageRecorded("agy", "conn-2"); + off(); + emitUsageRecorded("agy", "conn-2"); + assert.equal(calls, 1); +}); + +test("a throwing listener does not break emit for others", () => { + let reached = false; + const offBad = onUsageRecorded(() => { + throw new Error("boom"); + }); + const offGood = onUsageRecorded(() => { + reached = true; + }); + try { + assert.doesNotThrow(() => emitUsageRecorded("antigravity", "conn-3")); + assert.equal(reached, true); + } finally { + offBad(); + offGood(); + } +}); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 09701ddeb8..a24fe9c930 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -371,6 +371,119 @@ test("usage service covers Antigravity quota parsing, exclusions and forbidden a assert.match(forbidden.message, /forbidden/i); }); +test("usage service prefers Antigravity retrieveUserQuota over catalog quotaInfo", async () => { + globalThis.fetch = async (url) => { + const urlString = String(url); + + if (urlString.includes("loadCodeAssist")) { + return new Response( + JSON.stringify({ + allowedTiers: [{ id: "tier_pro", isDefault: true }], + cloudaicompanionProject: "ag-project", + }), + { status: 200 } + ); + } + + if (urlString.includes("fetchAvailableModels")) { + return new Response( + JSON.stringify({ + models: { + "gemini-3.5-flash-high": { + quotaInfo: { + remainingFraction: 1, + resetTime: new Date(Date.now() + 60_000).toISOString(), + }, + }, + }, + }), + { status: 200 } + ); + } + + if (urlString.includes("retrieveUserQuota")) { + return new Response( + JSON.stringify({ + buckets: [ + { + modelId: "gemini-3.5-flash-high", + remainingFraction: 0.25, + resetTime: new Date(Date.now() + 60_000).toISOString(), + }, + ], + }), + { status: 200 } + ); + } + + throw new Error(`unexpected fetch: ${url}`); + }; + + const usage: any = await usageService.getUsageForProvider({ + provider: "antigravity", + accessToken: `ag-token-live-quota-${Date.now()}`, + }); + + assert.equal(usage.quotas["gemini-3.5-flash-high"].remainingPercentage, 25); + assert.equal(usage.quotas["gemini-3.5-flash-high"].used, 750); + assert.equal(usage.quotas["gemini-3.5-flash-high"].quotaSource, "retrieveUserQuota"); +}); + +test("usage service normalizes retired Antigravity quota bucket ids", async () => { + globalThis.fetch = async (url) => { + const urlString = String(url); + + if (urlString.includes("loadCodeAssist")) { + return new Response( + JSON.stringify({ + allowedTiers: [{ id: "tier_pro", isDefault: true }], + cloudaicompanionProject: "ag-project", + }), + { status: 200 } + ); + } + + if (urlString.includes("fetchAvailableModels")) { + return new Response( + JSON.stringify({ + models: { + "gemini-3.5-flash-low": { quotaInfo: { remainingFraction: 1 } }, + "gemini-3.5-flash-high": { quotaInfo: { remainingFraction: 1 } }, + "gemini-3-flash-agent": { quotaInfo: { remainingFraction: 1 } }, + "gemini-3.5-flash-extra-low": { quotaInfo: { remainingFraction: 1 } }, + }, + }), + { status: 200 } + ); + } + + if (urlString.includes("retrieveUserQuota")) { + return new Response( + JSON.stringify({ + buckets: [ + { modelId: "gemini-3-flash-agent", remainingFraction: 0.5 }, + { modelId: "gemini-3.5-flash-extra-low", remainingFraction: 0.25 }, + ], + }), + { status: 200 } + ); + } + + throw new Error(`unexpected fetch: ${url}`); + }; + + const usage: any = await usageService.getUsageForProvider({ + provider: "antigravity", + accessToken: `ag-token-legacy-buckets-${Date.now()}`, + }); + + assert.equal(usage.quotas["gemini-3-flash-agent"], undefined); + assert.equal(usage.quotas["gemini-3.5-flash-extra-low"], undefined); + assert.equal(usage.quotas["gemini-3.5-flash-high"].remainingPercentage, 50); + assert.equal(usage.quotas["gemini-3.5-flash-medium"].remainingPercentage, 100); + assert.equal(usage.quotas["gemini-3.5-flash-low"].remainingPercentage, 25); +}); + test("usage service retries Antigravity fetchAvailableModels across the shared fallback order", async () => { const calls: any[] = []; const expectedQuotaUrls = getAntigravityFetchAvailableModelsUrls();