* fix(api): stop /v1/models rebuilding the catalog on almost every request
The response cache added by #6408 memoized the serialized body for
`modelCatalogCacheTtlMs`, defaulted to 1500 ms. On a real install the builder
takes far longer than that: measured on the production VPS, ~49 s for a 1.3 MB /
2645-model catalog. Any two requests more than 1.5 s apart therefore both missed
the fresh window, and the second fell into stale-while-revalidate — which
rebuilds via `setTimeout(…, 0)` and, because the builder is overwhelmingly
synchronous under the single-threaded App Router, pins the event loop, so even
the "served immediately" stale body only reaches the client once the rebuild
finishes. Net effect: ~50 s on essentially every call.
Measured on the VPS (1 cold build + 5 sequential requests):
cold = 48.93s
req1 = 3.19s ← the only hit
req2 = 50.51s req3 = 50.93s req4 = 47.43s req5 = 52.28s
Raise the default to 60 s. A short TTL is redundant with the invalidation this
cache already has: `invalidateDbCache()` bumps `modelCatalogCacheVersion` on
every settings/connections/combos/pricing write and
`dropCatalogCacheIfStateChanged()` drops the whole cache the moment it moves, so
post-write freshness never depended on the TTL. What the TTL governs is the
"nothing was written" case, where replaying a body built seconds ago is the
point of the cache. 60 s matches the ceiling the settings schema already allows
for the override, so the default can never exceed what an operator may configure.
The value that actually takes effect is the settings default, not the constant:
`catalog.ts` resolves `dbSettings.cache?.modelCatalogCacheTtlMs ??
CATALOG_CACHE_TTL_MS_DEFAULT`, and the `??` never falls through while a settings
default is declared. Raising only the constant is a silent no-op — which is how
the first attempt at this fix measured identical to no fix at all. All three
declarations are aligned and a test pins them together.
* docs(changelog): add fragment for #8833
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Adds GET /api/usage/cache-health and a Cache Health tab under
/dashboard/analytics, both backed by a pure summary over the cache columns
already present in call_logs.
Motivated by a production diagnosis where the aggregate ratio was actively
misleading. The window read 24.8M cached tokens and wrote 9.5M — a
write/read of 0.385, which reads as merely mediocre. The actual shape was
very different: the median call wrote 848 tokens while 18% of the calls
carried 94% of every written token, and two models in the same window sat
at 0.13 (Sonnet) and 0.51 (Opus). Averaging hid all three facts.
So the summary reports what the average cannot: the distribution
(p50/p90/p99), the concentration (how few calls carry how much of the
write), and the per-model split. The heavy-write threshold is relative to
the window (10x the median, floored at 1024) because a cutoff tuned for
130k-token conversations reports nothing at all on 2k-token ones; 1024 is
the minimum Anthropic bills for cache creation, below which a write carries
no signal.
Calls that neither read nor wrote are counted separately from thrash — a
route that does not cache is an absence of caching, not a sick cache — and
only successful calls are summarized, since a 4xx/5xx never reached the
provider cache and would dilute the ratio.
Tests cover the summary (8) and the route (6, against a real SQLite so the
WHERE clause itself is exercised), including that an internal failure
answers 500 without leaking the stack trace, the SQL text or a table name.
The handler awaited the DELETE and never looked at the response:
if (!confirm(t("removeConfirm"))) return;
await fetch(`/api/quota/pools/${id}`, { method: "DELETE" });
await mutate();
When the request failed — a 401 from an expired session, a 500, a dropped
connection — the page revalidated, the card stayed exactly where it was, and
nothing was shown. From the operator's side the click was indistinguishable
from a misclick, so the natural reaction is to click again. The page had no
error surface at all, unlike the sibling flow in the API manager which already
checked res.ok and rendered the message.
Adds a dismissible alert above the header, fed by both failure paths: a
non-ok response (appending the API's message when it sends one) and a thrown
request, which has no response to read. The alert clears on the next attempt,
so a transient failure does not leave a stale error on screen.
Reported against two boxes on 2026-07-28. The delete itself was working there
— the audit log shows the pools were removed — which is exactly what this
change makes visible either way.
Tests (vitest/jsdom, 5): non-ok response, thrown request, success path stays
quiet and still revalidates, a dismissed confirmation issues no DELETE at all,
and an earlier error clears once a later delete succeeds.
CodeQL js/polynomial-redos (alerts #765/#766).
`/\/+$/` has no left anchor, so the engine retries the match at every
start offset and each attempt re-walks the whole slash run before failing
`$` — O(n^2) on a connection baseUrl made of many slashes. Measured on
the vulnerable code: 10k slashes = 102ms, 30k = 968ms, 60k = 4028ms
(clean quadratic); through the public resolvers the same input took 22s.
providerSpecificData.baseUrl is operator-supplied config and reaches the
trim via isFamilyPresetUrl() -> normalizeEndpoint() and both resolve*Url()
helpers, so the input is reachable.
Replaces all three identical occurrences with a linear charCodeAt scan.
CodeQL only flagged two of them; the third (resolveAlibabaProviderModelsUrl)
carries the same defect and is fixed here rather than left behind.
Behavior is unchanged: every trailing slash is still removed (not a bounded
subset), interior slashes are preserved, and an all-slash string still
collapses to empty — asserted by the new behavior test.
* fix(api): enforce image generation API key auth
* fix(api): align image route auth guard with clientApiPolicy
The route-level guard added for image generation was stricter than the
authz middleware that already fronts /api/v1/* (src/proxy.ts →
clientApiPolicy), so requests the pipeline admits were 401'd by the
handler:
- A cookie-authenticated dashboard session was rejected under
REQUIRE_API_KEY=true. The dashboard Media page
(dashboard/cache/media) and the Playground call these routes with a
session and no Bearer — the same mismatch already fixed for
/api/playground/presets.
- A presented invalid key was rejected even with REQUIRE_API_KEY=false,
where clientApiPolicy (#2257) and the sibling /v1/embeddings and
/v1/web/fetch routes degrade a stale CLI key to anonymous instead.
Extract the shared guard into shared/utils/clientApiRouteAuth so both
image routes (and future /v1 handlers) mirror the middleware contract
instead of re-deriving it, and drop the now-dead auth imports.
Also switch the call-log attribution fallback back to `||`: with `??`,
an empty-string apiKeyId/apiKeyName would be persisted verbatim and
would block the request-scoped context, which the previous
`entry.apiKeyId || null` never did.
Tests: cover the dashboard-session and keyless-mode-invalid-key
branches, and split the auth/attribution cases into
image-generation-route-auth.test.ts to stay under the 800-line
new-test-file cap.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: alexey.nazarov@softmg.ru <alexey.nazarov@softmg.ru>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Dashboard Codex restrictions use the public cx/ alias while /v1/responses
normalizes bare Codex IDs to codex/, so allow/block checks falsely 403'd.
Expand permission candidates via the provider registry alias map.
Closes#8803
Extract ModelTable from the charts god-file and drive header clicks through a
single sort state object plus a pure sorter so column toggles reliably reorder
rows. Compute missing share pct from summary totals when the analytics API
omits it.
* fix(client): rewrite absolute fetch/EventSource paths under basePath
Absolute browser calls like fetch("/api/...") and new EventSource("/api/...")
do not honor Next.js basePath, so subpath deploys (OMNIROUTE_BASE_PATH) break
dashboard health checks, settings APIs, and SSE unless a reverse proxy rewrites
the domain root.
- Add withBasePath / getDeployBasePath helpers
- Install ref-counted fetch + EventSource rewrite when basePath is set
(same pattern as installDashboardCsrfFetch)
- Mount BasePathNetworkProvider at the root so login works too
- Mirror OMNIROUTE_BASE_PATH to NEXT_PUBLIC_OMNIROUTE_BASE_PATH for the client
- Document in .env.example; unit tests for rewrite rules
* docs(changelog): add fragment for #8515 basePath client fetch
* test(client): move basePath tests into a scanned dir and fix no-op call-shape asserts
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(client-sweep-8515): restore CHANGELOG #8471 bullet and fix basePath TS2322
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
All five diagnostics in src/lib/batches/dispatch.ts are the same:
Type '(request: any, context: any) => Promise<any>' is not assignable to
type 'BatchRouteHandler'.
Target signature provides too few arguments. Expected 2 or more, but got 1.
`withInjectionGuard()` returns `guardedHandler(request, context: any)` with the
second parameter required, so every route it wraps advertises arity 2. The batch
dispatcher's `BatchRouteHandler` is `(request: Request) => …`, and TS rejects
assigning a function that needs an argument the caller will never supply.
The declaration was wrong about its own runtime. `dispatch.ts:48` already calls
`handler(request)` with one argument, and has been doing so in production;
`context` is only forwarded to the inner handler, where routes that do not read
it get `undefined`. Marking it `context?: any` states what was already true.
208 -> 203, zero new, on a line-number-agnostic diff of the full tsc error set.
One character; nothing executable changed.
No test added: the one-argument call path is the existing behaviour and is
already covered — embeddings-auth.test.ts and embeddings-route-apikeymeta-6929
call `POST(req)` directly through withInjectionGuard, which is exactly the arity
this now permits. 370/370 across the 43 injection-guard / batch / embeddings /
moderation suites; typecheck:core, eslint and check:file-size clean.
Co-authored-by: backryun <busan011@ormbiz.co.kr>
* fix(auth): tag internal/loopback-origin failed logins in the audit log
Failed dashboard logins (`auth.login.failed`) are emitted only after a
submitted, non-empty password fails verification, and the recorded IP is
accurate. On a single-process deployment with no reverse proxy, a loopback /
private source IP therefore means the attempt genuinely originated on the box
or the LAN (someone browsing http://localhost and mistyping, or a browser
autofill replaying a stale password) — but the Audit Log had no way to
distinguish those from an external intrusion attempt, so they read as
suspicious noise.
Add `classifyIpScope()` to `ipUtils` (loopback / private / public / unknown,
using bounded string-prefix checks — ReDoS-safe) and stamp `sourceScope` +
`internalOrigin` onto the `auth.login.failed` audit metadata so the audit view
can label internal-origin failures distinctly. No change to which events are
written or to IP attribution.
Closes#8336
* test(auth): assert the new origin tags on the failed-login audit event
This PR tags failed logins with sourceScope/internalOrigin, but the
pre-existing admin-audit-events assertion still deepEqual'd the old
two-field metadata shape and broke. The request under test carries a
public x-forwarded-for, so the expected tags are sourceScope: "public"
and internalOrigin: false — the assertion stays strict, it just covers
the fields this PR introduces.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): restore task-aware routing config on restart (#8601)
The T05 Task-Aware Smart Routing config was persisted to settings.taskRouting
by PUT /api/settings/task-routing but never read back, so it silently reverted
to enabled:false + the hardcoded default model map on every restart.
Two root causes, both fixed:
- No boot hydration existed. Adds hydrateTaskRoutingConfig(settings), wired into
src/instrumentation-node.ts next to the Thinking-Budget restore (#5312). It
accepts either the JSON string the route persists or an already-parsed object,
and fails open on malformed values. applyRuntimeSettings does not cover this
key, same as the Global System Prompt (#2470).
- The config lived in a plain module-level `let`, which is duplicated per module
graph — a boot hydration would have landed on the instrumentation graph's copy
and never reached the one src/sse/handlers/chat.ts reads. This is the exact
break #5312 fix-A hit on the VPS. Moves the store to the globalThis pattern
already used by thinkingBudget.ts and systemPrompt.ts.
Runtime stats are never restored from the persisted blob.
Note the hydration is wired into instrumentation-node.ts, not the unused
src/server-init.ts.
* docs(changelog): add fragment for #8604 task-routing boot restore
* fix(sse): route task-aware defaults by intent, guard fitness pattern order (#8602, #8603)
Two related defects in the hand-maintained model-quality tables.
#8602 — DEFAULT_TASK_MODEL_MAP hardcoded literal provider/model ids
(openai/gpt-4o, gemini/gemini-2.5-flash-lite, deepseek/deepseek-chat, ...).
Wrong twice over: the ids rotted by a generation or two, and applyTaskAwareRouting
overwrites body.model directly, so a literal target skipped auto-combo's 13-factor
scoring (quota, circuit-breaker health, cost, latency, stability), connection
cooldown and model lockout — hard-failing for any operator with no connection for
that provider. Refreshing the strings would only reset the rot clock, so the
defaults now name auto/* INTENTS that resolve against the operator's actually
connected backends:
coding -> auto/coding
analysis -> auto/reasoning
vision -> auto/vision
summarization -> auto/chat:fast
background -> auto/chat:cheap
creative and chat stay pass-through. Operators can still pin a specific model via
PUT /api/settings/task-routing; only the shipped defaults change. No provider/model
literal remains in the module.
#8603 — the pattern-shadowing fix LANDED UPSTREAM while this PR was open
(9f5be229b, Train 1D). lookupStaticFitnessTable now ranks patterns longest-first,
so gpt-4o-mini no longer inherits gpt-4o's 0.9 and deepseek-v3.2 no longer inherits
deepseek-v3's 0.85. This PR therefore no longer changes that behaviour — the
upstream scan is kept verbatim.
What remains for #8603 is the regression guard. The resolution chain hits the DB
(user_override / arena_elo / models.dev tier) before reaching layer 4, so asserting
the ordering through getTaskFitness would depend on DB fixture state. The layer is
exposed as getStaticFitnessTableScore and pinned directly by
taskFitness-pattern-order-8603.test.ts (7 cases), so the guarantee survives future
edits to FITNESS_TABLE. Those 7 cases were written against this PR's original
implementation and pass unchanged against the upstream one — independent
confirmation that the two are behaviourally equivalent.
* docs(changelog): add fragment for #8605 task-routing intent + fitness order
* fix(plugins): delete the host script synchronously and stop two tests leaking child processes
Three related leaks in the plugin child-process lifecycle, found while tracing 27 node
processes on a developer machine.
1. loader.ts removed the generated omniroute-plugin-host-*.mjs with a fire-and-forget
`rm(...).catch(() => {})`. That unlink loses the race against process exit: test:unit
runs with --test-force-exit, which tears the process down before the promise settles,
so every plugin load leaked one temp .mjs into TMPDIR. Measured at 6 files per
full-suite run, 40 accumulated over a handful of local runs. rmSync closes the race;
the throw stays swallowed because an exception raised from a child "exit" handler
would take the server down, and a leftover temp script would not.
2. plugins-manager-lifecycle.test.ts "activates an installed plugin" called activate() --
which spawns the plugin's child process -- but never deactivate(). deactivate() is the
only path that reaches the loader's cleanup(), so the child outlived the test and its
IPC channel kept the test process's event loop alive.
3. plugins-manager-restart-reload-7806.test.ts simulateRestart() deleted the entry from
loadedPlugins without calling cleanup(), dropping the only handle that can kill child
#1. The reload then spawned child #2, and the finally block's deactivate() could reach
only child #2 -- one dangling child per test. A real restart takes the whole process
tree down, so calling cleanup() here is both the faithful simulation and the fix.
Combined effect: a run without --test-force-exit deadlocks. The test process cannot exit
while its child holds the IPC channel open, and the child waits for messages that never
come. Observed as three plugin hosts alive for 4h51m under a runner that never finished.
Validation (Hard Rule #18, TDD): the new plugins-loader.test.ts case fails against the old
async unlink ("must delete the host script synchronously, not on a later tick") and passes
with rmSync. It redirects TMPDIR/TEMP/TMP to a private directory before counting, because
test:unit runs at --test-concurrency=20 and a concurrent file's host scripts would
otherwise land in the counted directory and flake the assertion.
After: 19/19 pass across the three files, 0 temp scripts created, 0 orphan processes.
tests/unit/build/** 334/334; typecheck:core and eslint clean.
* docs(changelog): add fragment for plugin host script sync delete
Previously checked directly, ignoring database feature flag overrides configured via settings/UI. This update routes the check through so DB overrides are respected as intended.
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
* fix(db): preserve node sqlite open semantics
* test(db): keep node sqlite coverage off Bun
* chore(changelog): number native sqlite fallback
* test(db): register node sqlite tests only under Node
- KimiSponsorBanner/RiskNoticeBanner: read the localStorage dismissal flag
via useSyncExternalStore (server snapshot = visible) instead of a
useState lazy initializer, so the first client render matches SSR
(which has no localStorage) instead of diverging on hydration.
- usage/analytics route: key the per-model aggregation map by model name
alone instead of `${provider}::${model}`, matching the table's one-row-
per-model display and eliminating duplicate `key={m.model}` rows when a
model is served through multiple provider connections/accounts.
- CommandPalette: look up existing section/subgroup by id across the whole
list instead of only comparing to the previous item, so sections whose
children interleave root items and groups (e.g. omni-proxy) don't produce
two subgroups sharing the same "_root" key.
- Add the missing `compressionOutputStyle.ponytail` label/description to
all 43 locale message files (present in the style catalog but never
added to any locale, causing a MISSING_MESSAGE crash).
Co-authored-by: Gillz <gillz@Gillzs-MacBook-Pro.local>
* fix(oauth): show GitLab Duo setup before authorize error
Surface the OAuth app registration and env-var recipe in the Add
Connection modal before auto-starting authorize, and keep the same
shared copy for catalog authHint and the authorize fallback (#8688).
* fix(oauth): keep OAuthModal under file-size baseline for #8688
Extract waiting/error panels so the GitLab Duo setup step does not
trip the Fast Quality Gates file-size ratchet, and update the retry
Button regression guard for the extracted error step.
Monster API shuttered operations on 2026-06-30. Mark monsterapi entry as isDeprecated with deprecationReason in INFERENCE_HOSTS catalog.
Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Six declarations spell a byte buffer as bare `Buffer` / `Uint8Array`. Without
its type argument that widens to `ArrayBufferLike`, which also admits
`SharedArrayBuffer` — so the value is rejected at every Web API boundary it is
actually passed to: `BodyInit` for `new Response(...)` and `BlobPart` for
`new Blob([...])`.
Every one of them is already ArrayBuffer-backed at runtime. `hexToBytes()`
allocates with `new Uint8Array(len)`; `synthesizeGtts()` and `pcmToWav()` return
`Buffer.concat(...)`; `fetchRemoteImage()` returns
`Buffer.from(await response.arrayBuffer())`; `readPageResponseBody()` returns
`Buffer.from(body)`, which copies. The declarations were simply less specific
than the values, so this states what the code already guarantees.
Same fix#8533 applied to the multipart and gRPC-web bodies.
Fixes 5 of the 208 `tsc -p open-sse/tsconfig.json` diagnostics with no new ones:
3 in audioSpeech.ts (MiniMax hex, gTTS, Vertex Gemini TTS), 1 in
imageGeneration.ts (Topaz Blob upload) and 1 in browserBackedChat.ts.
Refs #8484
Consolidates PR #8660 (matchesAnyToken with full-match priority over
token-level OR fallback) with the existing Turkish search normalization
shipped on release/v3.8.49.
The PR adds a new matchesAnyToken helper used by the providers search
to allow space-separated queries (e.g. 'pollinations sambanova') to
match when any of the tokens is present. Full query match takes
priority so that an exact 'pollinations sambanova' query still hits
even if a partial token would have been ambiguous.
The unit test file gains 12 PR-side matchesAnyToken tests on top of
the 6 release-side Turkish-normalization tests covering the same
function, totaling 27 tests.
Co-authored-by: maxmad64bis <maxmad64bis@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
buildUnifiedModelsResponseCore builds the entire catalog first — every provider's
models, the auto/* combos with their candidate scoring, the embedding/image/rerank/
audio/moderation/video/music registries, the OpenRouter catalog, custom models —
and only at the very end, once the caller turns out to be scoped to a quota pool
(allowedQuotas non-empty), discards all of it and returns the pool's qtSd/* combos
instead.
Measured on a 1 vCPU shared-quota host: ~1.2s of CPU per cold build to return 10
models, and 4.4-7.1s under contention. Claude Code's gateway model discovery aborts
at 3s, so on a busy host the discovery silently falls back.
Everything the quota path needs (`combos`, `timestamp`, `buildComboCatalogMetadata`)
already exists before the expensive loops start, so resolve the key metadata there
and return early.
Extracts two helpers into ./catalogResponse so the short-circuit and the full build
share one implementation instead of two copies that drift:
- applyCatalogPostFilters — the chain that runs AFTER the API-key filter
(configuredOnly, claude effort variants, no-thinking variants, cc-discovery
mirrors, synced effort variants, dedupe)
- finalizeCatalogResponse — enrichment + the codex `models: []` compatibility
field + the response envelope
That chain is not optional for the quota path: the cc-discovery mirrors are exactly
what lets Claude Code see a quota pool's models, and a first draft of this change
dropped them by returning before it. The regression is now pinned by a test that
was verified to fail without the call.
catalog.ts 1615 -> 1480 lines.
TDD: tests/unit/quota-exclusive-catalog-short-circuit.test.ts uses the OpenRouter
catalog fetch as the observable — it is part of the full build and reaches the
network, so a request that performs it did the whole build. The quota request runs
first, while OpenRouter's 24h cache is still cold, otherwise a cached second call
would make the assertion vacuous. Red before (1 fetch), green after (0), and a
normal key still fetches.
Behaviour guards green: quota-exclusive-catalog-4806 (2), quota-key-models-route
(18), cc-discovery-aliases-catalog (9), catalog-helpers-extraction (12),
cc-compatible-model-catalog (1), instrumentation-warm-catalog-cache (3),
catalog-pricing-surface-8018 (3), apikeypolicy-quota-only (6). typecheck, lint and
the file-size gate clean.
The runner reads exactly one directory and records the bare numeric prefix as the
version, so the numeric slots are a single global namespace. Any distribution that
ships its own migrations next to the upstream set has to draw from that same range
while upstream keeps appending to it — and when both sides claim a number, the
runner records one name for it and treats the other as already applied. That
migration then never runs, silently, on every already-provisioned database.
OMNIROUTE_EXTRA_MIGRATIONS_DIRS registers additional directories as
`namespace=dir` entries separated by the platform path delimiter. Files found
there are recorded as `<namespace>-<number>` (e.g. `ee-134`), a version space that
cannot collide with the upstream numeric one, and they are applied after the core
set. Unset — the default, and the only case for a plain install — nothing changes:
no filesystem access, identical behaviour.
Misconfiguration throws instead of being skipped. A malformed entry, a namespace
outside [a-z][a-z0-9]*, a duplicate namespace, or a directory that does not exist
aborts startup, because silently missing schema is the exact failure this exists to
prevent. Two files sharing a number inside the SAME namespace still collide and
throw, mirroring the runner's own guard.
Also fixes an inconsistency the tests surfaced: a missing core directory returned
early and took the extra directories down with it. They are an independent set.
The version-namespaced strings need no further plumbing — the applied set, the gap
reconciliation and the name-mismatch check all key on the version string, and the
numeric-only paths (`Number.parseInt`, the "032"/"041"/"042" special cases,
`isSchemaAlreadyApplied`) ignore them by construction.
11 new tests; the 7 neighbouring migration suites stay green (62 tests).
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
The discovery-alias info button explains the gate and links to the flag, but the
operator still had to assemble the Claude Code config by hand from the guide.
Render it instead, on the same Claude tool card, from the base URL the card has
already resolved (custom override included, normalized — no /v1, no trailing
slash), with a copy button.
The key slot holds a placeholder, never the real key: this card renders before a
key is necessarily selected, and a real key in copyable text is an easy way to
leak one into a screenshot or a pasted snippet.
buildClaudeDiscoverySettingsSnippet is a pure builder in claudeCliConfig so the
shape is unit-tested (7 cases, including that no `sk-` ever reaches the output
and that an invalid CLAUDE_CODE_AUTO_COMPACT_WINDOW is dropped rather than
emitted). Guide gains the matching section; the five new strings are translated
for pt-BR and vi (the locales with strict parity/marker tests) and marked in the
rest, per the repo's convention.
* feat(db): cc discovery alias gate storage + EXPOSE_CC_DISCOVERY_ALIASES flag
Adds the gate for claude/<provider>/<model> discovery-alias mirror ids on
the /v1/models catalog: a new runtime feature flag (env forces on and wins
over the dashboard DB override), per-provider and per-model "on"/"off"/null
overrides stored in key_value under the ccDiscoveryAliases namespace, and a
pure precedence resolver (model > provider > global). Catalog wiring is a
separate follow-up task; this only lands the gate + storage.
* feat(sse): synthesize claude/ discovery aliases for the model catalog
* feat(api): advertise cc discovery aliases on /v1/models behind the 3-level gate
* feat(sse): resolve claude/ discovery aliases on the request path
* fix(sse): import getComboByName from db/combos, not the localDb barrel
* fix(sse): cover custom-node prefixes and the Codex WS bridge in cc discovery alias resolution
* feat(dashboard): cc discovery alias toggles + flag-screen env warning
Adds the operator-facing UI/API layer for the Claude Code discovery-alias
gate (claude/<provider>/<model> mirror ids on /v1/models): REST endpoint
for provider/model overrides, a provider-detail card with 3-state
(inherit/on/off) toggles, an info button on the Claude Code tool card
linking to Feature Flags, and an env-source warning on the
EXPOSE_CC_DISCOVERY_ALIASES flag card when it's forced on via env.
* feat(api): cc discovery usage metrics
* fix(api): record cc alias metric in the production wrapper + atomic counter upsert
* docs: document cc discovery aliases (Claude Code guide + feature flag catalog)
* fix(sse): don't mirror built-in auto/* combos as discovery aliases (advertised-but-unroutable)
* i18n(vi): translate the discovery-alias strings instead of shipping placeholders
vi is the one locale with a strict "no internal missing markers" test, so the 17
__MISSING__ entries this branch added (the provider ccAlias panel, the info
button, the feature-flag description and the env warning) would have turned that
test red the moment the base itself was repaired. Translated, keeping every ICU
placeholder ({modelId}, {error}) and the literal claude/<provider>/<model> id
shape intact.
* chore(quality): raise the frozen caps this feature legitimately grows
catalog.ts 1615 -> 1639: the alias synthesis is wired into the catalog builder,
which is where the per-key-filtered list is assembled — the only place the mirror
entries can be appended after model hiding has been applied.
localDb.ts 808 -> 810: two re-export lines for the new ccDiscoveryAliases db
module, which is exactly what the "Adding a New DB Module" recipe prescribes.
* refactor(dashboard,api): keep the complexity ratchets flat
The feature added four cyclomatic violations and one cognitive one, which the
ratchets reject — the baseline only moves when a metric improves. Split the new
code instead:
- appendCcDiscoveryAliases: the four skip-guards become isMirrorableId().
- resolveCcDiscoveryAliasStripWith: alias parsing and gate resolution become
parseCcAliasTarget() and resolveGateFor(), replacing a chain of ternaries that
each re-tested isComboAlias.
- FeatureFlagCard: the env-precedence warning becomes its own component instead
of a conditional branch inside an already-large render.
- ProviderCcAliasSection: the loader moves to useCcAliasData(), and the override
list and add-row become ModelOverrideList / AddOverrideRow, bringing both
oversized functions back under the 80-line rule.
Behavior unchanged — the 74 discovery-alias tests pass untouched. Both ratchets
now sit exactly at baseline (2188 / 971).
* fix(api): serve the model catalog stale-first and sanitize its error body
A client with a short discovery timeout (Claude Code allows 3s) hit a full
catalog rebuild — 290 providers plus SQLite reads — every time the memoized
entry expired, and got an empty model picker with no error. Serve an expired
entry immediately and revalidate in the background, bounded by a staleness
window so a permanently failing refresh cannot pin an old catalog forever.
Only a cached 200 is eligible; a state change still drops the cache outright.
The builder's catch block also returned the raw error message in the response
body. Route it through the shared sanitizer (hard rule #12).
* fix(api): reject a failed catalog refresh instead of resolving it stale
catalogInFlight is shared with the cold path, so resolving the background
refresh with the stale entry handed it to callers that had already aged past
CATALOG_STALE_WHILE_REVALIDATE_MS — a stale 200 they were no longer entitled to,
with a build failure disguised as success. The refresh now rejects; the stale
path never awaits it (the rejection is pre-handled, so it can never surface as
an unhandledRejection) and a cold-path caller that joins it gets the sanitized
500. A failed refresh still leaves the cached entry untouched.
Also sanitize the core builder's own catch — that is the realistically reachable
500 for this endpoint, and it still returned the raw error message (hard rule
#12); keep the cache-key format private to the module by having the two test
hooks take the Request and derive the key themselves.
* refactor(api): extract the model-catalog response cache into its own module
The stale-while-revalidate work pushed catalog.ts from 1615 to 1745 lines, past
its frozen size cap. Raising the cap on a file already flagged as too large is
the wrong answer: the caching layer is a self-contained concern (coalescing,
TTL memoization, staleness window, background refresh) that only needs a builder
callback from the catalog module.
catalogCache.ts now owns the maps, the cache key, the state-change invalidation,
the header merge, the background refresh and the test hooks; catalog.ts keeps
auth, the builder, and the error shape, and re-exports the hooks so the existing
tests keep importing them from where they always did.
Net effect: catalog.ts 1745 -> 1513, i.e. 102 lines below the cap it was frozen
at, and the test-only surface no longer sits in the production catalog module.
No behavior change — all 281 tests across every suite importing catalog.ts pass,
including the #6408 one-builder-run guard.
* fix: repair five base-red failures on release/v3.8.49
Every PR cut from this branch fails CI on the branch's own breakage. Five
distinct causes, none introduced by the PRs that trip over them:
1. dast-smoke / Turbopack build — src/sse/handlers/chat.ts imported
PROVIDER_BREAKER_FAILURE_STATUSES twice in one statement. A duplicate import
specifier is an ECMAScript syntax error, so the production build never
compiled. Introduced by #8258, whose export fix landed on top of an import
that already existed.
2. Unit Tests — the #8393 verified-cooldown bypass was renamed
exactCooldownVerified -> exactCooldownIsUpstreamReset during the #8254
conflict resolution, which also dropped the flag at the markAccountUnavailable
call site entirely. The rename left the test passing the old key (so the flag
was silently ignored and a verified upstream reset got clamped back to
maxCooldownMs), and the dropped call site meant no real caller set it at all.
Align the test on the surviving name, restore the call site, and restore the
doc comment explaining #6863 vs #7940.
3. Unit Tests — #8526 added four common.* keys to en.json only, breaking the
strict key-parity tests for pt-BR and vi. Translated into all 42 locales.
4. Unit Tests — vi carried 17 __MISSING__ placeholders from #8354 and #8463, and
vi is the one locale with a no-placeholder test. Translated.
5. No new ESLint warnings — four suppressed `any`s in
tests/unit/combo-routing-engine.test.ts no longer exist, and ESLint exits 2 on
stale suppressions. Pruned (271 -> 267); no other entry moved.
Also regenerates skills/cli-backup-sync/SKILL.md, which still documented the
`backup status` flags #8512 removed — the merge-integrity gate compares the
generated output against the tree.
Not fixed here: the env/docs contract (NEXT_PUBLIC_OMNIROUTE_BASE_PATH and
OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS missing from .env.example), which #8690
already covers, and the quality baselines, which #8686 covers.
* fix(quality): keep prettier off the generated SKILL.md files
check:agent-skills-sync diffs the generator's output against the tree byte for
byte, but lint-staged runs prettier over any staged *.md — and prettier inserts a
blank line after the frontmatter that the generator does not emit. Committing a
regenerated skill therefore made the gate fail again on the very file that was
just brought back in sync. The 44 untouched skills only escape this because they
never pass through lint-staged.
The generator is the formatter of record for these files, so ignore them.
* fix(i18n,quality): drop the stale zh-TW key; raise the auth.ts frozen cap
#8463 renamed `oauthModal.googleOAuthWarning` away but left the old key behind in
zh-TW, so the "the stale googleOAuthWarning key is GONE from every locale" guard
fails on the branch. Removed it.
The auth.ts frozen line cap goes 2486 -> 2492. Restoring the dropped
exactCooldownIsUpstreamReset call site costs 7 lines, and staging the file makes
lint-staged reformat four pre-existing over-100-column lines to prettier's rule —
unavoidable without bypassing the hook, which hard rule #10 forbids. The file
still sits 12 lines below where the cap was set relative to its actual size.
* fix(sse): export PROVIDER_BREAKER_FAILURE_STATUSES so chat.ts stops throwing ReferenceError
Base-red slice 4 (single root cause across the whole handleChat cluster).
src/sse/handlers/chat.ts:1273 references PROVIDER_BREAKER_FAILURE_STATUSES to decide
whether an all-rate-limited provider result should trip the provider breaker, but the
constant was only a FILE-LOCAL const in chatPredicates.ts (a refactor extracted it out
of chat.ts and never re-exported it). Every request that reached that branch threw
`ReferenceError: PROVIDER_BREAKER_FAILURE_STATUSES is not defined`, so the global
fallback and breaker-gate paths blew up — surfacing as "All models failed |
PROVIDER_BREAKER_FAILURE_STATUSES is not defined" and breaking the handleChat coverage
tests (combo-error passthrough, 503 for cooled-down/open-breaker, budget-error, model
cooldown, body-derived retry-after, non-JSON rate-limit bodies).
Fix: export the const from chatPredicates.ts and import it in chat.ts (one canonical
definition, restoring the pre-refactor behavior).
Validated: chat-route-coverage 15/0 (was 12/3), chat-cooldown-aware-retry 6/0,
chat-rate-limit-body-lock 2/0; breaker guards (7907, combo-breaker-429, openrouter-6842)
unchanged; typecheck:core clean.
* test(nvidia): read PROVIDER_BREAKER_FAILURE_STATUSES from chatPredicates.ts
Same root cause as the chat.ts import fix in this PR: the const was extracted out
of chat.ts into chatPredicates.ts, so the nvidia-quota Phase-1 guard (which greps
the source for the `= new Set([...])` declaration to prove 429 was not added to the
breaker classification) must read chatPredicates.ts, not chat.ts. Now 13/0.
---------
Co-authored-by: Probe Test <probe@example.com>
* fix(guardrails): Vision Bridge describe-fallback ignores unreachable candidates
getVisionCapableModels() scanned the entire static PROVIDER_MODELS catalog
for a describe-fallback model without checking whether the provider has a
usable active connection on this instance. On an instance with no `openai`
provider connected, this let the hardcoded default `openai/gpt-4o-mini` win
selection every time, the describe call would fail, and
replaceImageParts()'s describe-failure fallback (#4012) intentionally
preserves the raw image part — which then reaches a non-vision backend and
gets rejected with an opaque upstream error (e.g. "unknown variant
`image_url`, expected `text`").
Extract the credential-usability check already used by the whole-request
reroute path (visionBridge.ts's hasUsableCredentialsForModel /
isProviderConnectionUsable) into a shared visionBridgeCredentials.ts module,
and apply the same check to the describe-fallback candidate list in
visionBridgeRouter.ts. A confirmed-unusable connection (`false`) excludes a
candidate; an indeterminate result (`null`, e.g. no DB) fails open to
preserve existing behavior.
getVisionCapableModels/getBestVisionModel/getFallbackModels become async to
support the credential lookup; call sites and the existing unit test suite
are updated accordingly, plus new coverage for the exclusion behavior.
* fix(guardrails): fix flaky credential-mock race and move Vision Bridge router tests to a CI-blocking runner
The two new assertions added in this PR (excludes a candidate with no
usable active connection / selects a credentialed candidate over an
uncredentialed one) were correct — the failure was a genuine Vitest
race: getVisionCapableModels() fans out to hasUsableCredentialsForModel()
once per catalog entry via Promise.all, and dozens of concurrent
first-load `await import("@/lib/db/providers")` calls for the same
specifier under vi.mock() nondeterministically resolved against the real
module instead of the mock for some callers. Memoize the dynamic import
in visionBridgeCredentials.ts so it resolves exactly once and is reused,
which removes the race entirely (and is cheaper at runtime too).
Also: tests/unit/guardrails/visionBridgeRouter.test.tsx was never
collected by any CI-blocking gate — `test:unit`'s guardrails glob is
`*.test.ts` only, and `test:vitest` (vitest.mcp.config.ts) doesn't
include this directory; only the advisory `test:vitest:ui` picked it up.
Moved the suite to visionBridgeRouter.test.ts under node:test, threading
an optional `deps.hasUsableCredentials` injection point through
getBestVisionModel()/getFallbackModels() (consistent with the existing
deps pattern in visionBridge.ts) since this project's native test runner
has no supported ESM module-mocking mechanism.
Running the full guardrails suite together also surfaced that this PR's
own credential-exclusion feature silently broke the pre-existing
vision-bridge-callmodel.test.ts fallback-retry test: with zero seeded
provider connections in that test's isolated DATA_DIR, every fallback
candidate is now confirmed-unusable and excluded, leaving no fallback to
retry. Seeded one credentialed connection there so the fallback-retry
mechanics stay independent of the (unrelated) credential filter.
Refs #8433
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
* fix(oauth): explain the LAN-IP loopback mismatch with an actionable panel
#8046 already stops the doomed login when a PKCE_CALLBACK_SERVER_PROVIDERS
provider (codex / xai-oauth / grok-cli) is connected from a LAN IP, but it
explained itself as one long English sentence rendered in the generic red
"Connection failed" step. Two concrete problems with that:
- the operator had to parse prose to work out WHICH ports to forward, and the
command shipped with `<port>` / `<omniroute-host>` placeholders to resolve
by hand;
- it forwarded a single port. Both are required: the dashboard port is what
makes the origin true-localhost (a LAN origin never reaches the
callback-server branch at all), and the provider's fixed callback port is
where the browser is actually sent back to. Forwarding either one alone
still fails.
buildPkceLoopbackMismatchHint() now returns the diagnosis as structured data
with the detected host and both ports already filled in, and a dedicated
OAuthLoopbackMismatchPanel renders it as: what happened -> how to fix, in
three numbered steps with copy-to-clipboard fields. No "Try again" button —
retrying the same origin fails identically. The panel yields to the
paste-token tab so grok-cli (which is in both provider sets) never stacks the
two views.
The flat warning string stays exported for non-UI callers.
docs: REMOTE-MODE.md gains a "Connecting Codex / Grok on a remote install"
section with the fixed-callback table and the two-port tunnel, mirroring the
existing Antigravity section.
i18n: 9 new oauthModal keys, hand-written for en + pt-BR and propagated to the
remaining 40 locales as `__MISSING__:` sentinels (runtime falls back to the
clean English value per #7258).
* fix(oauth): correct the Antigravity remote-login guidance and drop the stale i18n copy
Same LAN-origin family as the codex fix in this branch, different mechanism and a
worse failure mode.
Google providers (antigravity / agy) have no fixed foreign port: OAuthModal builds
`http://127.0.0.1:<dashboardPort>/callback`. On a LAN origin that 127.0.0.1 is the
BROWSER's machine, and Google's firstparty/nativeapp consent only releases the code
once the loopback is reachable from the approving browser. When it is not, the consent
never redirects at all — it hangs. So unlike an ordinary provider there is no error
page and no callback URL in the address bar.
That made the existing copy actively wrong. `googleOAuthWarning` was corrected when
the login helper shipped (#5203), but a changed English value does not invalidate
existing translations and `i18n:sync-ui` only fills keys that are ABSENT, never ones
that are STALE — so 39 of 43 locales (pt-BR, pt, es, de, fr, ja, zh-CN, …) kept the
original "wait for the redirect, copy the full URL and paste it below", instructing a
flow that cannot complete. The drift gate that should have caught this is a no-op:
`check-translation-drift.mjs` needs `.i18n-state.json`, which is not in the repo, and
it runs `--warn`.
Because the key's MEANING changed, it is renamed rather than edited — a new key cannot
inherit a stale translation. `googleOAuthWarning` is removed from all 43 locales and
replaced by 7 `googleLoopback*` keys, hand-written for en + pt-BR and marked
`__MISSING__:` elsewhere so the runtime falls back to correct English (#7258).
UI: `OAuthGoogleLoopbackNotice` states what is happening and surfaces both real
remedies with the detected host and port filled in — the local login helper
(recommended; its blob is what the Step 2 field accepts) and a single-port SSH forward.
It also REPLACES `remoteAccessInfo` for this family instead of stacking on top of it:
that notice promises an error page whose URL you copy, true for ordinary providers and
false here.
`agy` deliberately gets no helper command. bin/cli/commands/login.mjs pins
PROVIDER = "antigravity" and parsePastedCredentials() rejects a blob whose embedded
provider does not match the route provider, so advertising the helper there would send
the operator to a blob guaranteed to be refused. It keeps the tunnel path.
Refactor: the shared `resolveDashboardPort` / `buildSshLocalForward` helpers move to
`loopbackTunnel.ts`, used by both hint builders. The codex builder's behaviour is
unchanged (its 11 tests still pass untouched).
docs: REMOTE-MODE.md notes that the dashboard now surfaces the remedies, states that
one forward is enough for Antigravity (contrasting the two-port codex case), and aligns
Option B's command on 127.0.0.1 to match what the UI generates.
---------
Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>