* feat(resilience): scope auto-disable banned accounts to subscriptions
Prepaid API keys should stay in the routing pool after a permanent-ban
signal; subscription/OAuth accounts can still be deactivated. Default
scope remains all so existing installs do not change.
* docs(security): document auto-disable scope and log skipped prepaid keys
Keep the operator ban-detection page aligned with the new setting and
reuse the shared scope enum in the settings schema and dashboard radios.
* chore(changelog): name the auto-disable scope fragment for #10617
* docs(settings): treat free login seats as auto-disable targets
The first-cut scope is still all vs login-style auth. Copy now states
that paid subscriptions and free accounts both disable, while prepaid
API keys stay in the pool until per-account overrides exist.
* i18n: backfill autoDisableBannedScope keys across all locales
npm run i18n:sync-ui — the 6 new autoDisableBannedScope* keys landed
in en.json and vi.json but not the other 40 locales (including
pt-BR), tripping the pt-BR no-drift regression test (#6695).
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(cli): let setup --api-key reach the provider setup path
`bin/cli/program.mjs` declares a program-level `--api-key` (the OmniRoute server
key) and `bin/cli/commands/setup.mjs` declares its own `--api-key` (the provider
key). Commander binds the value to the program-level option, so the subcommand's
`opts.apiKey` was always `undefined` and
omniroute setup --non-interactive --add-provider \
--provider openrouter --api-key sk-...
aborted with "Provider API key is required. Pass --api-key or OMNIROUTE_API_KEY."
— naming the very flag that had just been passed. The documented headless setup
path was unusable; the only way in was `omniroute keys add`.
Fall back to the program-level value in a small exported helper. This also makes
`OMNIROUTE_API_KEY` satisfy the provider key, which the error message already
promised (that env var feeds the program-level option).
Tests cover the real Commander flag shape, the env-var path, and precedence when
both are supplied.
* chore(changelog): use the real PR number for the fragment
* fix(cli): derive machine-id token under plain Node and honor salt rotation
`getCliToken()` destructured `machineIdSync` off `await import("node-machine-id")`.
That module is CommonJS, so under plain Node its exports land on `.default` and the
destructured binding is `undefined`. Calling it threw, the bare catch blanked the
token, and every management request went out with no `x-omniroute-cli-token` header
— silently unauthenticated, 401 on every `omniroute combo` / `usage budget` call.
Resolve the binding the same way `src/lib/machineToken.ts` already does, and read
`OMNIROUTE_CLI_SALT` so the rotation documented in docs/security/CLI_TOKEN.md
actually reaches CLI processes (the salt was hardcoded). The catch now logs instead
of failing mute, per the error-handling convention in CONTRIBUTING.md.
The existing test asserted `token === "" || token.length === 32`, so the blanked
token passed. Tightening it in-process is not enough either: the suite runs under
`tsx/esm`, which resolves CJS named exports and hides the bug. The regression test
therefore spawns plain `node` — the loader the CLI actually runs under.
Both new tests fail on the previous code and pass on this one.
* chore(changelog): use the real PR number for the fragment
* fix(xai): cap chat history at xAI 800-message limit
xAI returns 413 when messages/input exceed 800 items. Token
compression never fires on a long tool loop that still fits the
context window, so trim at the executor edge after Responses
expansion and drop orphaned tool pairs from the cut.
* chore(changelog): attach PR number to xAI 800-message fragment
* fix(xai): resolve TS2339 generic assignment in capXaiRequestHistory
Drop the T extends Record<string, unknown> generic on
capXaiRequestHistory and type it directly as
Record<string, unknown> -> Record<string, unknown>. Assigning
next.messages / next.input onto a generic T was rejected by
TypeScript even though every call site already passes/consumes a
JsonRecord (= Record<string, unknown>), so no caller relied on the
generic preserving a narrower type.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: mikolaj92 <mikolaj92@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(audio): fall back nested STT models when the prefix provider has no credentials
Bare ids such as deepgram/nova-3 prefix-match the native provider and 400
when that key is missing, even if OpenRouter lists the same model. Retry
the gateway and mention qualified catalog ids in the error.
Closes#10583
* test(audio): scope whisper-1 fallback test to a 2-provider registry
nanogpt was added to AUDIO_TRANSCRIPTION_PROVIDERS (already merged,
unrelated to this fix) with a bare "whisper-1" model id, which now
intercepts findAlternateAudioProvider's first candidate before the
qualified-alias branch this test exists to cover. Scope the test to a
local {openai, openrouter} registry subset so it deterministically
exercises the qualified `${provider}/${model}` fallback regardless of
future providers that also list a bare "whisper-1" id.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(providers): complete Jina AI via OmniRoute including Omni multimodal
Dashboard and env keys share one Jina credential pool, native v5 Omni
{text}/{image}/{content} docs pass through /v1/embeddings intact, and
classify/segment/search are proxied without a third unused Jina card.
* chore(changelog): name Jina complete-provider fragment for #10581
* feat(providers): make Gemini Embedding 2 multimodal work via OmniRoute
Route gemini-embedding-2 through embedContent/batchEmbedContents so N
OpenAI input items become N vectors, pass through native multimodal
parts, and use dashboard Gemini keys (GEMINI_API_KEY only as fallback).
* fix(providers): resolve rebase fallout for Jina/Gemini embeddings
- narrow the two new no-explicit-any violations introduced by this PR
(validateJinaFoundationProvider's params + catch, search.ts's
normalizeJinaSearchResponse data param)
- cast credentials to Record<string, unknown> at the two quota-preflight
call sites in src/sse/services/auth.ts so the new JinaEnvCredentials /
GeminiEnvCredentials union members type-check without loosening the
allRateLimited narrowing used elsewhere in the same function
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Add githubSkillTools to getAllToolDefinitions() so the searchable MCP
catalog matches TOTAL_MCP_TOOL_COUNT, which already counts them. The
GitHub skill tools were registered and counted but missing from the
catalog, so omniroute_tool_search could not surface them.
Adds regression tests at both layers: catalog aggregation and
client-visible discovery via the MCP client.
Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com>
* fix(opencode): session stability, free-tier routing, and CLI defaults
- Wire generateSessionId() into opencodeHeaders so x-opencode-session
is a deterministic fingerprint instead of randomUUID() per request,
enabling upstream prompt caching across a conversation
- Thread request body through buildHeaders() so session fingerprint
has access to model, system, messages, and tools
- Default CLI header synthesis to ON (opt-out via false), align
values with 9router proven defaults (opencode/desktop/global)
- Auto-echo listing-valid model names for noAuth providers so
response.model matches /v1/models listing
- Short-circuit free-tier model resolution to opencode provider first
to prevent prefix inference misrouting when catalog is unreachable
* fix(opencode): make free-tier default flip self-consistent + add coverage
PR #10571 flipped OPENCODE_SYNTHESIZE_CLI_HEADERS to on-by-default and
changed the synthesized UA/client/project default values, but shipped
with 2 broken assertions in the existing #5997 regression test and no
coverage for the new session-fingerprinting, free-tier routing, or
noAuth echoModel logic (Hard Rule #18).
- Update tests/unit/opencode-cli-headers-synthesis-5997.test.ts to match
the new on-by-default behavior and new default values; add an explicit
opt-out coverage test so the forward-only path is still guarded.
- Fix 20 further test failures in tests/unit/opencode-executor.test.ts
and tests/unit/refactor-buildHeaders-opencode.test.ts caused by the
same default flip (pin OPENCODE_SYNTHESIZE_CLI_HEADERS=false for the
characterization suites that predate #10571; use a genuinely
CLI-looking UA where the preserved-UA test requires one).
- Fix a real bug found via TDD while adding the mandated free-tier
routing regression test: the big-pickle/*-free short-circuit in
open-sse/services/model.ts checked activeProviders?.has("opencode")
literally, but getActiveProviderSet() canonicalizes every connection's
provider id through resolveProviderAlias(), which rewrites "opencode"
to "opencode-zen" via a manual override — so an active no-auth
opencode connection could never satisfy the check. Now checks both
opencode-family candidate ids. Proven with a test that fails on the
original code and passes with the fix (both connections active with a
stale synced catalog omitting big-pickle).
- Extract the noAuth-provider echoModel aliasing in chatCore.ts into a
pure, directly-testable helper (open-sse/handlers/chatCore/noAuthEchoModel.ts),
matching the existing chatCore god-file decomposition pattern.
- Add regression tests for generateSessionId()-based x-opencode-session
fingerprinting (stable within a conversation, changes on model/message
changes), the free-tier routing short-circuit, and the noAuth echoModel
aliasing.
- Add the changelog.d/ fragment and sync docs/reference/ENVIRONMENT.md's
OPENCODE_SYNTHESIZE_CLI_HEADERS/OPENCODE_USER_AGENT/OPENCODE_CLIENT/
OPENCODE_PROJECT rows to the new defaults.
Does NOT resolve whether flipping OPENCODE_SYNTHESIZE_CLI_HEADERS's
default was the right call, and does NOT touch the separate open PR
#10357 which flips the same flag with a different literal default value
- that decision is left to the maintainer at merge time.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
GET /v1/models listed google/gemini-embedding-001 but omitted
google/gemini-embedding-2 even though that id already returns 3072-d vectors.
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Native gemini-embedding-2 400s with a dead-end credentials error even though
openrouter/google/gemini-embedding-2 already serves 3072-d vectors.
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(a2a): use a constant-time bearer compare in /api/a2a/tasks
* fix(a2a): drop new Function from tasks-auth test in favor of dynamic import
The regression test for the constant-time bearer compare loaded tokensMatch
and authenticateA2A by regex-extracting their source and eval'ing it via
new Function, which trips the repo's no-new-func/no-implied-eval ESLint
rules (error-level everywhere, including tests). Export both helpers as a
test seam from the route module (mirrors the existing
bridgeSecretMatches/authRouteInternals pattern) and import them directly
in the test instead. Also drops the now-unused eslint-disable directives.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(providers): optional AI Horde API key and live image catalog
Allow a registered Horde key on the no-auth connection and send it for
chat and image jobs. List only image models that currently have workers,
and generate through Horde's native async API.
# Conflicts:
# open-sse/config/imageRegistry.ts
# src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
# src/shared/constants/providers.ts
# src/sse/services/auth.ts
* fix(providers): validate AI Horde keys against find_user
The OpenAI-compatible /v1/models probe returns 200 for any Bearer token
on oai.aihorde.net, so Check always succeeded. Use Horde's /v2/find_user
lookup instead; an empty key still counts as the optional anonymous path.
* chore(changelog): name the AI Horde fragment for #10542
* fix(images): harden AI Horde optional-key selection and outbound fetches
- Optional-key selection now honors connection health (rate-limit cooldown
and terminal/unavailable test status) before handing a stored key back,
rotating to the next healthy key or falling back to the anonymous no-auth
path instead of using an unhealthy stored key.
- Route the Horde submit/check/status/cancel and catalog calls through the
repository's bounded outbound-fetch helper (timeout, no more bare fetch())
and route R2 image downloads through the established bounded remote-image
fetch (SSRF host guard, DNS-rebinding pin, streaming byte cap, redirect
limit) instead of an unbounded fetch().
- Extend the generation deadline to cover the full request lifecycle
(catalog freshness check, submit, polling, and image download), and add a
regression test proving that exceeding the deadline issues a DELETE
cancel to Horde's API rather than only timing out locally.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: pqr <pqr@soraka.ititti.es>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(providers): test token-backed web sessions
* fix(providers): restrict token-web-session test dispatch to validated providers
Narrow shouldUseApiKeyConnectionTest to the token-kind web-session providers
that actually have a token-aware connection validator (deepseek-web, kimi-web,
tinycms-web, copilot-m365-web, copilot-web, zai-web). WEB_SESSION_CREDENTIAL_REQUIREMENTS
marks more providers as kind: "token" than have a matching validator in
SPECIALTY_VALIDATORS (hailuo-web, microsoft-designer-web, t3-chat-web, promptql) — those
were falling through to the generic cookie-based validateWebCookieProvider probe, which
sends the stored credential as a Cookie header and treats most non-401/403 responses as
valid, so an invalid token could be reported as a healthy connection.
Add regression coverage for hailuo-web and promptql (plus microsoft-designer-web and
t3-chat-web) proving they stay off the API-key test path, and for every currently
validated token-kind provider proving they still use it.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(dashboard): count live usage_history rows in Free Tier 'used this month' (#10381)
* fix(dashboard): use an indexable UTC month-range predicate for used-this-month (#10381)
sumUsageTokensThisMonth() filtered usage_history with
substr(timestamp, 1, 7) = strftime('%Y-%m', 'now') — a substr() expression
SQLite cannot use a range index on, and fragile against any timestamp
that isn't exactly ISO-shaped. Replace with an indexable inclusive-start/
exclusive-end UTC range: timestamp >= <month start> AND timestamp <
<next month start>, matching the ISO 8601 format saveRequestUsage()
already writes.
Adds a boundary regression test: the first instant of the current month
is included, the last instant of the previous month is excluded, and a
next-month row is excluded too (covers the upper bound substr() could
never express).
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(combo): surface context-overflow before compression so oversized requests fail fast with a clear error (#10225)
* fix(combo): make context-overflow deferral target-aware for native Codex passthrough (#10225)
The deferral added by the prior commit checked only operator-named
compression exclusions when deciding whether at least one target "can
compress" — it never accounted for native Codex Responses passthrough
targets, which chatCore.ts unconditionally excludes from compression
(compressionExcluded = nativeCodexPassthrough || ...). Deferring on such
a target's account let an oversized request skip both the combo preflight
AND compression, reaching fetch() uncompressed.
Thread the same request-shape facts chatCore.ts uses
(shouldUseNativeCodexPassthrough: provider/sourceFormat/endpointPath/body/
headers) down into getKnownContextOverflow so the deferral decision can
never drift from chatCore's own — a native-codex-passthrough target now
never counts as "compressible", so a pool made only of such targets keeps
the fast local 400 instead of a wasted round trip.
Adds regression coverage: the pure getKnownContextOverflow target-aware
check, an end-to-end handleComboChat proof that a native-codex-only pool
fails fast with zero dispatches, and two real handleChatCore-path tests
proving compression actually reduces the dispatched body when eligible,
and that a still-too-large-after-compression request is rejected locally
without an upstream call.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(resilience): keep combo quality and auth reasons separate and redact connection labels in terminal errors (#10314)
* fix(resilience): sanitize identifiers in error text, add explicit terminal-status policy, fix classifier ordering (#10314)
Four gaps in the prior combo-error-aggregation fix:
- formatComboOutcomes() only redacted connection identifiers in the model
label, never in the raw upstream error TEXT — a proxy echoing a
connection/account id back in its error body leaked it into the
client-facing terminal message. Redact both.
- The terminal HTTP status was still `lastStatus` — whichever target
happened to fail last, independent of the other targets' reasons. Add
resolveComboTerminalStatus(): preserve a 4xx only when every eligible
target's failure is genuinely "the request is invalid" (model-class);
a heterogeneous mix (e.g. a quality failure + a sibling's 401) now
normalizes to a 5xx-class status reflecting an infra/provider problem,
never a misleading client error borrowed from an unrelated target.
- classifyComboOutcome()'s ordering had `status === 408 || status >= 499`
checked before `status >= 500`, making the provider branch permanently
unreachable — every real 5xx (500/502/503/504) was silently mislabeled
as "timeout". Fixed to an exact match (408/499) and gave 429 its own
explicit `rate_limit` kind instead of falling into the generic "model"
(request-invalid) bucket by accident.
- Added an integration-level regression driving the real handleComboChat
wiring end-to-end (quality failure + sibling 401, and a success-after-
quality-failure case), not just the pure aggregation helpers.
Updated three pre-existing tests whose assertions encoded the OLD
last-writer-wins contract this fix intentionally supersedes (#8486 Part B
antigravity retryAfter tests, two combo-routing-engine status/message
tests) to the new, more precise contract; verified the underlying #8486
concern (wrong target's retryAfter header) is still honored under the new
status policy.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(providers): fall back to public Code Suggestions endpoint on GitLab Duo direct_access 401 (#10365)
* fix(providers): extend GitLab Duo 401 fallback to the connection-test path (#10365)
The chat-completion path (open-sse/executors/gitlab.ts) already falls back to
the public Code Suggestions completions endpoint when the direct_access
exchange is rejected with 401, but testOAuthConnection() / the dashboard
Retest button still reported the connection unhealthy on the same 401 —
even though a real chat request through that connection would have
succeeded via the fallback. Apply the identical fallback contract to the
connection-test path (first attempt and the post-refresh retry), sharing the
predicate with the executor via shouldFallbackToPublicCodeSuggestions.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(memory): auto-check Qdrant health on mount and stop false-red badge
The Qdrant engine card on /dashboard/memory?tab=engine showed a red
"Error" badge after every page refresh even when Qdrant was healthy:
the badge derives its state from a health check, but the mount effect
only fetched settings + embedding models — health started as null and
the render treated `health?.ok` (undefined) as a failure. Clicking
"Test connection" (which runs the same server-side /readyz check)
immediately turned it green, proving the connection was fine.
Two changes:
- Auto-run the health check on mount once settings load and Qdrant is
enabled, so a refreshed page reflects the real state (verified live:
/api/settings/qdrant/health returns ok:true in ~2ms on a healthy
compose deployment).
- While health has not been checked yet (null), render a neutral gray
"Testing..." state instead of red — red is now reserved for an
actual failed health check.
Regression test added (fails on the old code): with enabled settings
and a healthy mock, the card must hit /api/settings/qdrant/health on
mount and show statusActive, never statusError.
* chore(changelog): fragment for #10489
* Merge branch 'release/v3.8.50' into fix/qdrant-health-badge
* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base
- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
(2026-08-16 16:00 UTC) is in the past, making every quota entry classify
as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
the regular-not-optional intent is unchanged.
* test(fix): align optional-transformers-dependency with onnxruntime ~1.24.3 pin (base #10543)
* docs(fix): sync 150-migration count and document PROXY_LOG_INCLUDE_IPS (base drift #10348/#10507)
* fix(memory): re-check Qdrant health after saving settings
save() optimistically flipped enabled and started the PUT while the mount
effect could immediately GET /api/settings/qdrant/health against the OLD
persisted settings. If that GET won, it returned not_configured/failed and -
because health was non-null - the effect never retried after the PUT
succeeded, leaving a healthy Qdrant red until a manual Test connection.
Invalidate health (generation counter + setHealth(null)) at save start and
after a successful PUT, then explicitly schedule a fresh check: setting
health to null alone is not enough, React bails on the no-op when health is
already null (the exact GET-wins ordering). Stale responses are dropped via
the sequence guard so an in-flight pre-save check can never overwrite the
post-save result. Adds a regression test covering enable ordering.
Addresses PR #10489 review finding (issuecomment-5312271806).
* fix: narrow omniglyph transform result union (merge base aa912c42a typecheck gate)
* test(compression): align contract tests with base aa912c42a merge (providerTransport shape, engine metadata)
* fix(memory): silence set-state-in-effect on Qdrant auto health-check
The health-check re-check fix (3469234) introduced an effect that calls
checkHealth() (an async fetch that eventually calls setState) directly
from a useEffect gated on loading/enabled/health. The
react-hooks/set-state-in-effect rule flags this as a potential cascading
render, matching the same pattern already accepted elsewhere in the
dashboard (FreePoolTab.tsx, ConnectionsTable.tsx) for gated async
data-fetch effects. Suppress with the established inline convention;
no behavior change.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(memory): drop unused set-state-in-effect disable (rule inert on pinned react-hooks 7.0.1)
The eslint-disable-next-line for react-hooks/set-state-in-effect is unused:
eslint-plugin-react-hooks@7.0.1 (lockfile-pinned) does not report this rule,
so the directive itself was flagged as a warning and the 'No new ESLint
warnings' CI gate failed with --max-warnings 0. The effect body only calls
checkHealth() (async fetch) with no raw setState, so no disable is needed.
* ci(quality): sync ratchet configs to release/v3.8.50 (0a74bfbde) merge
- re-freeze open-sse typecheck baseline at merged-tree live counts
(64 stale entries dropped, 11 frozen; base video/usage drift covered)
- register tests/unit/video-bridge-drilldown-route.test.ts in stryker tap.testFiles
- regenerate skills/cli-contexts/SKILL.md (contexts migrate docs from CLI closure)
---------
Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): downgrade adaptive thinking and gate context-1m beta on model eligibility (#10119)
* fix(sse): thread resolved model into DefaultExecutor's anthropic-beta merge (#10119)
DefaultExecutor.buildHeaders() merged the client-negotiated anthropic-beta
header without ever passing the resolved target model into
mergeClientAnthropicBeta(), so the context-1m-2025-08-07 eligibility gate
added earlier in this PR could not see which model a combo/fallback had
actually routed to at this call site. buildHeaders() now accepts an
optional model parameter (mirroring BaseExecutor.buildHeaders' existing
signature and the pattern already used by grok-cli.ts/qoder.ts) and
forwards it through, so an ineligible model target (e.g. Haiku) has the
beta dropped instead of forwarded blind.
Restores a CHANGELOG bullet (PR #10366) that a prior merge auto-resolve
had dropped from this branch.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(admission): resolve adaptive latency-collapse self-lock with solo-progress and idle recovery (#10111)
* fix(admission): refresh recovery ceiling on updateConfig (#10111)
updateConfig() clamped currentLimit to the new min/maxLimit but left
recoveryCeiling pinned to the value computed at construction time, so
a raised initialLimit could never recover past the stale ceiling and
a lowered one could leave the ceiling above the new maxLimit.
Recompute recoveryCeiling from the new initialLimit on every
updateConfig call, clamped to the (possibly also new) min/maxLimit.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(usage): wire agentrouter balance quota into dashboard Quota UI (#10078)
* fix(usage): render AgentRouter wallet balance as USD in the Quota UI (#10078)
The prior fix wired AgentRouter's balance into getUsageForProvider() and
USAGE_SUPPORTED_PROVIDERS, but the actual dollar figure never reached the
Dashboard Quota UI: quotas.balance.remaining carried a synthetic two-state
percent (100/0) instead of the real dollarBalance, and the Provider Limits
renderer only formats a row as "$X.XX" when isCredits/currency/creditCount
are set, which the generic quota-parsing path never sets. A configured
balance rendered as a bare "100% left" percentage, not USD.
Shape quotas.balance.remaining as the real USD amount (clamped to 0) and add
an agentrouter branch to quotaParsing.ts that builds a credits-style row
(same buildCreditsQuota() pattern as DeepSeek/Claude extra-usage), so a
configured balance shows a currency-formatted dollar amount and an
exhausted balance always renders as exactly $0.00.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(dashboard): send periodic WS heartbeat pings to stop live-dashboard reconnect churn
The live-dashboard WS client (src/hooks/useLiveDashboard.ts) only sent a
subscribe frame on open and never emitted the protocol's { type: "ping" }
heartbeat. The server (src/server/ws/liveServer.ts) refreshes client
liveness only from inbound messages and terminates any client idle past
HEARTBEAT_TIMEOUT_MS (35s), so a healthy, connected-but-idle dashboard
client was force-terminated roughly every 35-45s, causing constant
reconnect churn (#10319).
Fix (both directions, per the analyzed plan):
- Client: start a 15s ping interval on open, cleared on close/unmount/
reconnect, so the connection stays inside the server's liveness window.
- Server (defense in depth): the outbound heartbeat pong now also bumps
client.lastActivity, so even a third-party client that never pings is
not dropped for being idle.
Regression coverage:
- tests/unit/useLiveDashboard-heartbeat.test.tsx: fast fake-timer check
that the hook emits periodic ping frames and cleans up the interval on
close/unmount (no leaked timers).
- tests/integration/live-ws-heartbeat-keepalive.test.ts: real WS-server
integration test asserting a silent-but-subscribed client stays
connected past the 35s heartbeat timeout (~50s window), converted from
the plan file's TDD RED repro.
Closes#10319
* fix(dashboard): stop renewing stale LiveWS sockets
Keep application-level heartbeat responses from refreshing server liveness, and add a regression covering silent stale sockets alongside clients that answer protocol heartbeats.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(dashboard): make provider card warning indicators expose the interaction they advertise
The usage-risk indicator (subscriptionRisk) promised "click for details" in its
tooltip but was a bare <span> with no onClick/role/dialog. The connection
warning-count badge exposed neither a title tooltip (reasons) nor any click
affordance, even though the reasons already exist in
providerSpecificData.apiKeyHealth[].
Turn the risk indicator into a real <button role/aria-haspopup="dialog"> that
opens an accessible Modal reusing the existing riskNotice copy, and wrap the
warning badge in a keyboard- and pointer-interactive control that surfaces a
sanitized reasons summary (max failure count + relative last-failure time,
never raw upstream error text) and navigates to the connection detail/health
view on activation. Both indicators are now visually distinct (bare icon vs.
pill Badge).
Closes#10261
* i18n(providers): sync riskNotice.detailsTitle + warningNotice keys to all 42 locales (#10261)
Real Vietnamese translations (vi.json has a strict no-__MISSING__-marker gate);
other 41 locales carry the sync-ui __MISSING__ placeholder pending the normal
translation pass.
* test(dashboard): relocate provider warning regression test
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(open-sse): stop concurrent requests colliding on dedup hash for non-OpenAI formats
computeRequestHash() in requestDedup.ts projected the prompt content from
body.messages only. The dedup site in chatCore.ts hashes the *translated*
(target-format) request body, and non-OpenAI target formats don't carry a
messages field: Gemini-translated bodies use `contents`, Responses-API
bodies use `input`. So for those formats messages was always undefined,
every prompt hashed to the same null-backed value for a given model, and
concurrent requests with different prompts joined the same in-flight
promise -- the second caller silently received the first caller's
response verbatim (#10249).
Fix: project body.messages ?? body.contents ?? body.input ?? null instead
of only body.messages, keeping the rest of the canonical hash projection
unchanged. Genuinely identical concurrent requests still dedupe (the
intended perf behavior); different prompts under Gemini/Responses-API
target formats no longer collide.
Regression test: tests/unit/request-dedup-10249.test.ts reproduces the
two collision scenarios from the plan-file (Gemini `contents`,
Responses-API `input`), confirms the OpenAI `messages` case was already
correct, and asserts identical-request dedup keeps working. Verified
RED (byte-identical hashes 0b24fd88.../dc16d5b7... pre-fix) -> GREEN
(distinct hashes, dedup preserved) against this exact diff.
* fix(open-sse): cover nested translator shapes + system fields in dedup hash (#10438)
computeRequestHash() only read top-level body.messages ?? body.contents ??
body.input, but several translated request shapes nest their prompt
content: the Antigravity Cloud Code envelope under request.contents, and
Kiro under conversationState.currentMessage.userInputMessage.content (plus
conversationState.history). Two different concurrent prompts to those
targets could hash identically and share/leak a response between callers.
Adds extractPromptContent()/extractSystemContent() helpers covering every
prompt-bearing shape produced by open-sse/translator/request/*.ts
(OpenAI/Cursor messages, Claude messages+system, Gemini contents+
systemInstruction, Responses input+instructions, Antigravity and Kiro
nesting), and folds system/instructions/systemInstruction into the
canonical hash so two requests with the same user message but a different
system prompt no longer collide either.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(sse): gate structural chat admission shedding on real heap pressure
Closes#10183, Closes#10268
3.8.49 (#9654/#9940) replaced the 3.8.48 heap-ratio shed
(heapUsed/heapLimit >= 0.75) in chatBodyAdmission.ts with an
unconditional CHAT_MAX_HEAVY_IN_FLIGHT=1 structural lease. A second
concurrent "structurally heavy" chat request (>=200 messages, >=64
tools, or >=32k estimated tokens — routine for coding-agent fan-out
like Hermes/Cursor/Claude Code) was hard-rejected with a retryable
HTTP 503 chat_admission_busy/structure_limit regardless of actual
heap pressure, even on a host with ample free RAM.
Restore the heap-conditional gate as an ADDITIONAL check layered on
top of (not a replacement for) the #9654 bounded-concurrency /
per-connection-lane protection: when heavyweight capacity is busy,
only enter the bounded-wait/shed path when a live heap-pressure probe
(heapUsed / v8 heap_size_limit >= OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO,
default 0.75) confirms real pressure. A healthy heap now admits the
second heavy request immediately via a no-op lease instead of parking
or shedding it. The probe is injectable via
admitChatStructure({ heapPressureCheck }) for deterministic tests.
Regression tests:
- tests/unit/bug-10183-admission-heavy-healthy-heap.test.ts (new,
permanent): healthy-heap 2nd heavy request now admitted (was RED);
genuinely pressured heap still sheds it.
- tests/unit/probe-10268-structural-503.test.ts (promoted to
permanent): the exact reported 503 chat_admission_busy shape is
still produced under real heap pressure, and the same fan-out is
admitted on a healthy heap.
- tests/unit/chat-body-admission.test.ts,
tests/unit/chat-body-admission-queue.test.ts,
tests/unit/per-connection-admission-9654.test.ts updated to inject
heapPressureCheck: () => true where they exercise the busy/shed
path, preserving #9654/#4380 coverage.
Gates run: npm run typecheck:core (clean), eslint --suppressions-location
config/quality/eslint-suppressions.json on changed files (clean),
scripts/check/check-file-size.mjs (OK), scripts/check/check-test-discovery.mjs
(OK), focused admission suite (68/68 passing) and npm run test:unit
(in progress at commit time under heavy shared-devbox contention from
a 13-way parallel session fan-out; no admission-related failures
observed through 1873 lines of output, the sole failure seen was a
pre-existing unrelated proxy/search timeout consistent with known
load-induced flakiness, not a regression from this change).
⚠️ base-red inherited: #9985 — ESLint errors (2) from #10250
* docs(env): document OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO (#10183, #10268)
* fix(sse): bound the healthy-heap admission fast path (#10437)
The #10183/#10268 fix admitted a busy heavyweight request immediately
whenever the heap was healthy, via an unconditional no-op lease with no
bound of its own -- an unlimited number of "healthy heap" requests could
pile in ahead of the heap-pressure shed path, defeating the point of
admission control.
Adds an independent, bounded healthy-heap headroom budget
(CHAT_ADMISSION_HEALTHY_HEADROOM, tryAcquireHealthyHeadroom()) that the
healthy-heap fast path draws from; once exhausted, requests fall through
to the same bounded-wait/shed path used under real heap pressure, which
is otherwise unchanged. Also fixes a pre-existing gap in
per-connection-admission-9654.test.ts's shared-budget test, which needed
an explicit heapPressureCheck override to keep exercising the #10110
invariant now that a healthy heap gets bounded headroom instead of an
outright reject.
* docs(env): document OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM in .env.example
Documented in docs/reference/ENVIRONMENT.md but missing from .env.example,
caught by the env-doc-sync gate when combined with other PRs in the
release merge-train.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(antigravity): strip trailing model turn for native Gemini requests too
Newer Gemini endpoints reject a request ending on a model turn with HTTP
400 'Requests ending with a model turn are not supported' — the same
rejection class Claude hits via Vertex. transformRequest() previously
wired stripTrailingAntigravityAssistantTurn() only into the isClaude
branch, so native Gemini models routed through Antigravity kept a
trailing role:model entry and hit the 400.
Extend the guarded strip (never empties contents) to native Gemini
models too, gated by upstreamModel including "gemini". The Claude
path is untouched (byte-identical), preserving PR #6114's live
validation against Vertex Claude.
Flips tests/unit/antigravity-claude-prefill-strip.test.ts test (b),
which previously asserted the buggy pass-through, and adds (b2) for
the gemini-3-flash-agent tier.
Closes#10104
* fix(antigravity): scope Gemini trailing-turn workaround
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(sse): bridge generic compatible-provider type id to concrete node id in credential lookup
getProviderSearchPool only bridged a provider string to a node id via the
node's prefix, never via the generic derived type id
(openai-compatible-chat / openai-compatible-responses / anthropic-compatible)
that resolveProviderNodeForConnection already accepts at connection-creation
time (#4421). A connection persisted under the generic type id was therefore
unreachable when the chat path resolved the concrete uuid node id, surfacing
"No active credentials for provider: openai-compatible-chat-<uuid>" even
though the key and model catalog were valid.
Closes#10085
* fix(sse): register #10085 mutation-coverage test file in stryker.conf.json
check:mutation-test-coverage --strict flagged
tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts as a
covering test for src/sse/services/auth.ts that was missing from
stryker.conf.json's tap.testFiles, per the CI Fast Quality Gates run
on PR #10434.
* fix(sse): disambiguate compatible provider credential lookup
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): require unambiguous type in both credential-lookup bridge directions (#10434)
getProviderSearchPool()'s generic-type<->concrete-node-id bridge (#4421,
#10085) only applied the "exactly one node of this derived type" ambiguity
guard to the concrete-id -> generic-type direction. The generic-type ->
concrete-id direction added every node sharing a derived type to the
search pool unconditionally, so a bare generic-type lookup could resolve
to a connection scoped to one specific node's baseUrl/headers even when a
second node shares the same derived type -- leaking that node's
credentials/upstream URL into an unrelated node's request.
Both directions now share the same typeIsUnambiguous gate, mirroring the
rule already enforced by selectProviderNodeForConnection() for connection
creation (src/lib/db/providerNodeSelect.ts, #4421).
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(dashboard): remap Kimi Code API-key save to admitted managed id (#10096)
The unified Kimi Code card's API-key branch posted provider: "kimi-coding"
to POST /api/providers. "kimi-coding" is an OAuth-primary managed id, not
an admitted API-key/dual-auth connection id, so the backend correctly
rejected it with 400 "Invalid provider" even though key validation passed.
Add resolveApiKeySaveProviderId() in useApiKeySave.ts to remap the posted
provider id to the dedicated, admitted managed API-key id
"kimi-coding-apikey" for the API-key save flow only. The OAuth flow
(handleOAuthSuccess in ProviderDetailPageClient.tsx) never calls this hook
and keeps posting "kimi-coding" unchanged.
Regression test: tests/unit/bug-10096-kimi-coding-apikey-save.test.ts
* fix(dashboard): remap Kimi Code bulk API-key save
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs
The subscription fetch guard (fetchGuard.ts) unconditionally blocked all
loopback/private IP ranges as SSRF protection, but the same feature already
permits loopback for the routing half (coreEndpoint.ts's
ALLOWED_LOCAL_CORE_HOSTS) — so an operator could route traffic through a
loopback core but could not fetch a proxy list from a loopback HTTP server.
Make the fetch guard local-first by reusing the existing
areLocalProviderUrlsAllowed() policy (default ON) from
outboundUrlGuardPolicy.ts: loopback/private hosts are now allowed as fetch
targets by default, while cloud-metadata/link-local (169.254.0.0/16, incl.
169.254.169.254 IMDS) and the unspecified address stay blocked
unconditionally, mirroring the provider-validation guard's "block-metadata"
mode. Callers that want the old strict behavior can pass
{ allowLocal: false }.
Closes#10158.
* fix(proxy-subscriptions): unwrap IPv4-mapped IPv6 + full fe80::/10 range (#10416)
The #10158 SSRF guard left two gaps on the IPv6 side: an IPv4-mapped IPv6
literal (::ffff:a.b.c.d) skipped IPv4 range checking entirely, and the
link-local check only matched strings literally prefixed with "fe80"
instead of the full fe80::/10 range (fe80:: - febf:ffff::), so fe90::,
febf:ffff::, etc. were wrongly allowed through.
isIpv6Blocked() now unwraps mapped IPv4 addresses (both the dotted-quad
and WHATWG-normalized hex-group forms) and re-checks them against the
IPv4 rules, and link-local detection parses the first hex group's numeric
value against the 0xfe80-0xfebf range instead of a string prefix.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(translator): Normalize tool call names from lowercase to PascalCase (#1)
* Fix: Map lowercase tool names from Antigravity (Gemini format) to Claude Code expected PascalCase
* Fix: toolNameMap in fun restoreClaudePassthroughToolUseName
* fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses (OpenAI, Gemini, Antigravity) to Claude Messages API format
This resolves `Error: No such tool available: read`/`bash`/`write` errors when using Claude Code CLI with third-party providers that emit lowercase tool names. The fix adds case-insensitive tool name lookups in `openai-to-claude.ts`, `gemini-to-claude.ts`, and related translators, ensuring tool names like `read`/`bash` are mapped to `Read`/`Bash` before being sent to Claude Code. Includes unit tests and comprehensive changelog notes ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250))
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats fr… (#2)
* fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats from model output
Some models (DeepSeek, Qwen) emit tool calls as text instead of proper
tool_calls JSON: either <tool_call>{...}</tool_call> or TOOL_CALL Name: {...}.
Extend extractXmlInvokeBlocks to handle all 3 formats in a single scan pass,
picking whichever pattern appears first. Includes unit tests for all formats.
* fix(translator): Parse text-format tool calls in gemini-to-claude translator
Extend the Gemini->Claude translator to detect <invoke>, <tool_call> JSON,
and TOOL_CALL text formats emitted inline in text parts (Antigravity/Gemini
models), converting them to proper tool_use content blocks instead of leaking
raw text to Claude Code.
* docs(changelog): Add changelog entry for text tool call parsing fix
* fix(translator): consolidate tool name casing normalization and restore thought-signature persistence (#3)
* fix(translator): sanitize tool_use.id and tool_result.tool_use_id to match Anthropic schema (#4)
Ensure tool IDs from OpenAI-compatible upstreams (which may contain dots, colons, or special characters) are sanitized to ^[a-zA-Z0-9_-]+$ in response translators and passthrough requests before reaching Claude endpoints.
* fix(responses): preserve native tools for openai-compatible Responses targets (#5)
A Responses-shaped request to a custom openai-compatible connection whose
outbound protocol is Responses took a Responses -> Chat -> Responses round
trip, so Codex custom tools lost their grammar (`exec`), namespace groups were
flattened (`collaboration`), and tool invocations failed upstream.
Gate a native Responses passthrough on the connection's configured protocol
(`apiType: "responses"` / `_omnirouteForceResponsesUpstream`) so the original
tool definitions reach a Responses-capable upstream unchanged. Chat-only
connections keep the existing downgrade.
Closes#10374
* fix(translator): add support for 'applypatch' tool name in tool call checks
* test(translator): add unit test for apply_patch and applypatch tool name remapping
* fix(translator): remove no-explicit-any lint errors in tool-use-id-sanitization test
Type the openaiToClaudeResponse/translateNonStreamingResponse return
values with narrow local shapes instead of `any`, satisfying the
repo's no-explicit-any = error rule for tests/. No behavior change —
the same 3 assertions still pass.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* test: update 9568 casing regression to match #10392's consolidated fix
restoreClaudeToolName's static casing map now normalizes known
lowercase tool names to canonical PascalCase unconditionally on the
gemini-to-claude and openai-to-claude Claude Messages API paths (not
gated behind toolNameMap), superseding the earlier per-map-only fix
that the original #9568 regression test locked in as "expected" (it
was previously labeled a known bug case). The gemini-to-openai
passthrough path is unaffected by #10392 and keeps its original
pass-through assertion.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Family resolves like auto/zai with no connected models logged a warn
on every call (about once a minute per poll). Keep the empty-pool
behavior; emit the warn at most once per label per 60s.
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* perf(logging): offload call-log artifacts to a worker
* test(call-log): raise drain wait timeout for cold worker spawn
The first cold spawn of the worker_threads artifact worker can take ~2.4s
before queued artifact writes start draining, so a 2s wait in
call-log-save-drain.test.ts flakes on cold runs. Raise it to 10s.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
The 9 media *ExampleCard components under media-providers/components used
the masked value from useApiKey() (sk-xxxx****yyyy) as an Authorization:
Bearer header, which the gateway always rejects (AUTH_002) once
REQUIRE_API_KEY is enabled. Mirror the LlmChatCard fix (#3503): authenticate
via the dashboard session (credentials: "same-origin") and forward the
selected key's id via x-omniroute-playground-key-id instead of its secret.
buildCurl now keeps the <your-api-key> placeholder instead of the masked
value.
Adds tests/unit/bug-9935-masked-bearer.test.ts as the permanent regression
guard (asserts none of the 9 cards embed apiKey as a raw Bearer token).
Refs #9935
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Validated in local merge-train-equivalent focused gate on release/v3.8.50 tip 9081b57146: catalog fingerprint regression + existing catalog-cache callers, 8 tests passed.
* fix(usage): read Gemini usageMetadata out of the antigravity response envelope
Port decolua/9router#59d858b: antigravity/gemini-cli wrap non-streaming
payloads in { response: {...} }, so extractUsageFromResponse only saw the
top-level usageMetadata and every non-streaming antigravity request logged
zero usage (IN 0 | OUT 0) and zeroed usage-dashboard rows. Top-level
metadata keeps priority; OpenAI/Claude branches untouched.
* chore(changelog): fragment for #10430 antigravity usage envelope
* fix(usage): surface Gemini cachedContentTokenCount as cached_tokens
Review follow-up on #10430: the Gemini branch of extractUsageFromResponse
ignored cachedContentTokenCount, so non-streaming cache-hit tokens never
reached the cached_tokens field the OpenAI/Claude/Responses branches
already populate (and the streaming path surfaces at usageTracking.ts:684).
Adds cached_tokens: usageMetadata.cachedContentTokenCount || 0, updates
the three Gemini assertions (envelope fixture already carried
cachedContentTokenCount: 7), and adds a dedicated regression test.
* chore(changelog): fragment for #10465 Gemini cached_tokens surfacing
* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base
- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
(2026-08-16 16:00 UTC) is in the past, making every quota entry classify
as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
the regular-not-optional intent is unchanged.
---------
Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
* fix(docker): real image tags + complete OMNIROUTE_BASE_PATH runtime patcher
Three docker issues fixed:
1. Images that do not exist:
- bifrost: ghcr.io/maximhq/bifrost:1.5.21 never existed (1.5.x tops at
v1.5.16, all tags carry the v prefix) -> ghcr.io/maximhq/bifrost:v1.6.11
- cliproxyapi: ghcr.io/router-for-me/* is not publicly pullable (403);
the official prebuilt image is docker.io/eceasy/cli-proxy-api, where
the pinned v6.9.7 exists -> docker.io/eceasy/cli-proxy-api:v6.9.7
- Verified still-current: redis:8.6.5-alpine (already on Redis 8 since
#9065; ioredis 5.10 is RESP2/3-compatible, no modules used) and
qdrant:v1.12.4 -- both exist, unchanged.
2. OMNIROUTE_BASE_PATH ignored on prebuilt images (root cause):
Next 16 (webpack and Turbopack) app-router renders SSR asset URLs from
assetPrefix ALONE; basePath only affects routing. The runtime patcher
(ensure-docker-base-path) rewrote basePath literals only, so a prebuilt
root-path image patched to /omniroute served the page but every
/_next/static shell reference stayed unprefixed (404 behind a subpath
proxy), the RSC flight-payload chunk refs came from client-reference
manifests baked with unprefixed paths, and the Turbopack client process
shim ships an empty env object so the client never learns the subpath.
Extended patch-standalone-base-path.mjs to also rewrite:
- assetPrefix literals (mirrors the subpath for SSR asset URLs)
- the NEXT_PUBLIC_OMNIROUTE_BASE_PATH env mirror in the inline config
- the client process.env shim (.env={}) with the two basePath keys
- every baked "/_next/static URL (manifests, media imports, .html pages)
next.config.mjs now mirrors basePath into assetPrefix so REBUILT images
bake prefixed assets too. E2E-verified on the published main-web image:
HTML under /omniroute now has 16/16 prefixed JS srcs and 82/82 prefixed
flight refs (was 13/9 + ~150 unprefixed), prefixed assets return 200.
* chore(changelog): fragment for #10482 (docker images + basepath patcher)
* chore(changelog): bullet-form fragment for #10482
* Merge branch 'release/v3.8.50' into fix/docker-compose-images-and-basepath
* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base
- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
(2026-08-16 16:00 UTC) is in the past, making every quota entry classify
as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
the regular-not-optional intent is unchanged.
---------
Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
* fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding
Accounts with an empty Cloud Code projectId get a permanent 422 "Missing
Google projectId" when loadCodeAssist returns no project. The 3.8.50
bootstrap attempts to CREATE the project via onboardUser, but a single failed
attempt (transient network/upstream error) was memoized forever in
onboardAttemptedCache: every later request in the process skipped onboarding
and 422'd, even though a retry would succeed.
Replace the permanent per-token Set with a failure-backoff map: failed onboard
attempts are retried after a 5-minute backoff (bounded, self-healing), the
in-flight lock still dedupes concurrent calls, and success clears the failure
marker and memoizes the project as before. Accounts that CAN be onboarded now
heal automatically on a later request or token refresh — no user action.
Tests: the existing "does not retry" case is now framed as the backoff window;
a new case proves the account heals (retries onboarding and recovers the
project) once the backoff expires.
* chore(changelog): fragment for #10424 antigravity project autocreate
* feat(antigravity): BYOP fast-fail + manual GCP project-id override
Port decolua/9router#2934 + VansRouter 802a859:
- tryOnboardUser now returns a three-way status; a 200 onboardUser response
WITHOUT cloudaicompanionProject means Google deprecated automatic project
creation for standard-tier (personal) accounts (BYOP). Such accounts are
cached permanently (no pointless ~18s re-onboard) and the executor fails
fast with 403 GCP_PROJECT_REQUIRED + actionable 'enter your project id'
message instead of the generic 422 or a delayed 429.
- Transient onboard failures keep the existing 5-min backoff heal.
- Manual project-id override: the EditConnectionModal now stamps
providerSpecificData.isProjectIdManual when the operator enters a project
id, and tokenRefresh skips auto-discovery for flagged accounts so the
manual value is never overwritten.
* chore(changelog): cover BYOP fast-fail + manual override in #10424 fragment
* test(antigravity): expect fast 403 GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#10424)
Google now marks accounts without an onboarded project as BYOP (automatic
project creation deprecated for standard-tier accounts, #2934). The PR's
BYOP fast-fail path returns 403 gcp_project_required instead of the old
generic 422 missing_project_id; align the #2334 executor test with that
contract so CI unit-test shard 2/4 passes.
* fix(antigravity): persist isProjectIdManual, fix BYOP citation, dodge refresh-retry
Review follow-up on #10424:
1. EditConnectionModal: isProjectIdManual was set on
updates.providerSpecificData right after the project-id field, then the
OAuth path (Antigravity is always OAuth) rebuilt providerSpecificData from
connection.providerSpecificData before the request went out, discarding the
flag — tokenRefresh.ts was guarding a field never actually persisted. The
flag now lands in the single surviving antigravity merge, with a jsdom
regression test (modeled on edit-connection-modal-openai-store-toggle).
2. The '#2934' citation for the Google BYOP claim pointed at an unrelated
closed issue. Swapped for the real tracking issue #8491 (empty Google
projectId -> 422 class) across bootstrap/executor/test comments.
3. BYOP fast-fail now returns 422 instead of 403: chatCore's generic
401/403 -> refresh-and-retry path was hitting Google's OAuth token
endpoint on every request from an affected account (pointless — refreshing
cannot create a GCP project), and 422 matches the sibling
missing_project_id error the client already maps to an action-needed
prompt.
Also: eslint-disable-next-line for the pre-existing
react-hooks/set-state-in-effect baseline noise in the modal (repo
convention, same pattern as 11 other dashboard files).
* chore(ci): drop unused eslint-disable in EditConnectionModal form hydration
The react-hooks/set-state-in-effect disable added in the previous commit is
unused under the repo's pinned eslint-plugin-react-hooks (7.0.1) — the rule
does not fire on this line at that version, so the unused directive tripped
the whole-repo 'No new ESLint warnings' gate (max-warnings 0). Verified with
the lockfile-pinned plugin: lint:json is clean (0 errors, 0 warnings).
* fix(build): bound and retry the opencode-plugin npm install in prepublish
The plugin's node_modules is gitignored, so every fresh CI checkout runs a
full npm install inside @omniroute/opencode-plugin during build:cli. npm's
unbounded fetch retries turn a stalled registry CDN connection (the recurring
onnxruntime-class ETIMEDOUT flake) into a 20-30 minute hang — the DAST
'Build CLI bundle' step has been cancelled at the 30m cap repeatedly.
- Bound npm fetch: --fetch-timeout 60s, 2 retries with capped backoff — a
stalled connection now fails fast instead of hanging the job.
- Retry the install up to 3 times with a 10s pause between attempts, so
transient CDN failures recover in-build.
Net effect: the step either completes (network OK) or fails quickly with a
clear error (network down) — it can no longer eat the whole job budget.
* ci(quality): use the npm-ci-retry action on every install step
Fast Quality Gates failed on the recurring onnxruntime-node postinstall
ETIMEDOUT (Microsoft CDN 150.171.x.x) - the same transient flake that has
hit Vitest and dast-smoke today. Only the Build job used the retry action;
the other five jobs (Docs, Fast Quality Gates, Vitest, Unit Tests,
changelog) still ran a bare install and die on any CDN hiccup. Use the
existing retry action (3 attempts, exponential backoff) on every install
step for consistency.
* Merge branch 'release/v3.8.50' into fix/antigravity-project-autocreate
* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base
- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
(2026-08-16 16:00 UTC) is in the past, making every quota entry classify
as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
the regular-not-optional intent is unchanged.
* test(fix): widen modelsDevSync lastSync wait from 200ms default to 2000ms
The truthy-spellings loop asserted each enabled case completes its first
fetch within waitFor's 200ms default timeout, which trips under CI runner
load (observed on PR 10424 shard 2/4). Match the file's other lastSync
waits (2000ms) so the sync-completion assertion is load-tolerant.
---------
Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)
Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.
Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.
npm audit → 0 vulnerabilities.
* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)
_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.
* Hide health-check excluded models from /v1/models catalog (#10026)
Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
* fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)
* fix(models): memoize getModelsDevPricing for /v1/models catalog
resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).
Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.
Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)
Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
so resetDbInstance() clears the process-local memo, preventing stale
pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).
The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.
Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts
---------
Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(sse): keep Codex quota headers under the forwarding budget
The 768-byte cap plus priority-3 for any name that does not contain
"ratelimit" dropped x-codex-*-used-percent / reset / credits on every
stream. x-codex-turn-state (314 bytes) ate the budget. Raise the cap,
treat Codex quota headers as rate-limit priority, and do not forward
turn-state.
---------
Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: ritheshcn25 <rithesh.chandran@snb.ca>
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(db): align compression_run_telemetry cleanup cutoff with millisecond column
cleanupCompressionRunTelemetry() computed its cutoff in epoch seconds while
insertCompressionRunTelemetryRow() stamps the timestamp column with Date.now()
(epoch milliseconds). A millisecond timestamp is ~1000x larger than a seconds
cutoff, so DELETE WHERE timestamp < cutoff never matched an old row and the
retention sweep added by #6848 to bound storage.sqlite growth was inert.
This is the same defect as domain_cost_history (#9625), whose fix corrected
cleanupDomainCostHistory() ~90 lines earlier in this file and missed this
sibling call site. The stale docstring asserting a unix-epoch column is
corrected too.
The repro test seeds through the real writer to establish the stored unit, so
it also fails if the producer format diverges from the consumer again.
* docs(changelog): add fragment for the telemetry retention unit fix
* fix(db): tolerate a SQLite build without the dbstat virtual table
getDatabaseStats() queried `dbstat` once per table with no guard. `dbstat` is
compile-time optional (ENABLE_DBSTAT_VTAB) and is absent from sql.js/WASM
builds, so on those runtimes the query throws and the error propagates out of
getDatabaseStats().
Every caller dies with it. Most visibly, GET and PATCH /api/settings/database
return HTTP 500, which makes the entire database settings page unusable — users
cannot read or change page size, cache size, or vacuum settings.
The function already anticipated missing virtual-table modules: the COUNT(*)
lookup a few lines above swallows "no such module:" errors. The dbstat query
simply sat outside that guard.
Probe dbstat once per call and skip the per-table size lookups when it is
unavailable, reporting size 0. Database-level figures (total size, page count,
cache size) come from pragmas and stay accurate; only per-table byte sizes are
lost, which is the correct trade against a hard 500.
Unrelated failures (I/O errors, corruption) still propagate.
Both spellings are handled: sql.js reports "no such module: dbstat" while
better-sqlite3 can surface "no such table: dbstat".
* test(db): cover prefixed driver errors and dbstat edge cases
Review follow-up on the previous commit.
The guard is deliberately unanchored because real drivers stringify errors
with their class name attached ("SqliteError: no such table: dbstat",
"RuntimeError: ..."). Nothing pinned that, so anchoring the regex would have
passed the suite while silently breaking every real driver. Add a case for the
prefixed form; it fails if a caret is introduced.
Also cover three shapes the fake previously could not express:
- a database with no user tables, which is what a fresh install hits first
- SUM(pgsize) returning NULL for a table occupying no pages
- dbstat answering the probe but failing on a later table, which documents
that a mid-iteration fault still propagates rather than being mistaken for
an absent module
Correct the source comment: the two error spellings track the SQLite build,
not the driver package, so the earlier attribution to better-sqlite3 was
wrong.
* docs(changelog): add fragment for the dbstat availability guard
Registers the new test with Stryker alongside the sibling db suites and adds
the changelog fragment for this fix.
---------
Co-authored-by: Nick Sullivan <nick@technick.ai>