* fix(providers): add per-provider opt-out for anonymous no-auth fallback
API-key providers with anonymousFallback: true (opencode-go, opencode-zen,
pollinations, kilocode) receive a synthetic "noauth" connection whenever all
real connections are terminal (credits_exhausted/banned/expired) or
unavailable. The opencode upstream now rejects anonymous requests with
401 Missing API key, so the fallback adds a guaranteed-failing round trip
and health/reconnect noise before the combo moves on.
Add a noAuthFallbackDisabledProviders settings array (zod-validated,
persisted via /api/settings, following the blockedProviders pattern).
When a provider is listed, maybeSyntheticNoAuthFallback returns null for
anonymousFallback-only providers, so exhausted providers are skipped
immediately as allExpired/allRateLimited while real keyed connections keep
working and recover automatically once quota state clears. True no-auth
providers are unaffected; blockedProviders remains their disable mechanism.
Default (absent/empty list) preserves current behavior.
Provider detail pages for anonymousFallback providers gain an
"Anonymous fallback" toggle (default ON) backed by the new setting.
Refs #9674
* fix(auth): reduce file size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Hermes Agent <hermes@hermes-chloe.hyades.io>
Wire Openference as a first-party OAuth gateway (PKCE, rotating refresh)
and an API-key catalog entry on api.openference.com, with live model
discovery, connection testing, free-tier badges, and regression tests.
Co-authored-by: Anh Tran <anhlead@outlook.com>
A phone that previously loaded a production build on this origin (or
an old dev build from before the registration was gated) kept an
active service worker across dev restarts. It intercepted every
navigation/asset fetch, occasionally serving a JS chunk that didn't
match the running dev server, which tripped Next's dev-client
chunk-mismatch auto-reload — visible as an unexplained, unstoppable
refresh loop on that device only (confirmed via a clean private tab
on the same phone/URL not looping).
PwaRegister now actively unregisters any existing service worker
registrations and clears their caches outside production, instead of
just skipping a new registration.
(cherry picked from commit 66a2515cbc)
Co-authored-by: Markus Hartung <mail@hartmark.se>
* feat(logging): make the chat-log truncation limit configurable, bumped default 128x
The 8KB cap on logged request/response bodies
(open-sse/handlers/chatCore/logTruncation.ts::truncateForLog()) was
hardcoded — trivially exceeded by any real multi-turn agentic
conversation, meaning the dashboard's "Full Conversation" panel could
only ever show a placeholder instead of the actual messages for nearly
every logged row of any conversation with real substance.
- Added CHAT_LOG_MAX_BODY_KB env var (src/lib/logEnv.ts::
getChatLogMaxBodyBytes()), default 1024 KB (1MB) — a 128x bump from
the old hardcoded 8KB — following the same configurable-limit pattern
as the sibling CHAT_LOG_TEXT_LIMIT/CHAT_LOG_ARRAY_TAIL_ITEMS/etc. vars.
- Documented in .env.example and docs/reference/ENVIRONMENT.md.
estimateSizeFast() (open-sse/utils/estimateSize.ts) has been
substantially rewritten upstream since this bug was first found (now an
iterative Frame-based walker with a separate node-visit budget, not the
simple stack loop originally patched) — re-implemented the fix against
the current algorithm rather than porting the old diff: the byte
early-exit was unconditionally the module-level ESTIMATE_SIZE_BYTE_LIMIT
(256 KiB) with no way for a caller to raise it, so any caller comparing
against a bigger configured threshold could never see a size above
~256 KiB — every payload between 256 KiB and the caller's real limit
looked "under threshold" and truncation never fired, the opposite of
intended. Added an optional byteLimit parameter (default unchanged at
ESTIMATE_SIZE_BYTE_LIMIT, so isSmallEnoughForSemanticCache's existing
behavior is untouched) threaded through both the byte-check early-exit
and the node-budget-exhaustion fail-closed fallback, with
truncateForLog() now passing its own configured getChatLogMaxBodyBytes()
value through.
* feat(dashboard): show conversation session tag in request detail metadata
Adds a "Conversation" field to the request detail panel's metadata
grid (after "Combo"), showing the request's conversation id
(sessionTag) for quick reference/copy.
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
* fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main
Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici)
applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies,
and mermaid.
npm audit: 6→0 vulnerabilities.
Closes Dependabot #161-#166.
* 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.
* feat(telegram): Mini App chat bridge — initData auth, update webhook, chat proxy
Implements the Phase-1 slice of the Telegram Mini App integration
(docs/proposals/TELEGRAM-MINIAPP.md):
- src/lib/telegram/initData.ts — dependency-free WebApp initData HMAC-SHA256
verification (Telegram Bot API spec), with auth_date freshness check.
- src/lib/telegram/config.ts — TELEGRAM_BOT_TOKEN / model / API base / timeout
env config; token format validation; enabled gate.
- src/lib/telegram/botApi.ts — minimal fetch-based Bot API client
(sendMessage, editMessageText, setWebhook) + update shape helpers.
- src/lib/telegram/chatProxy.ts — maps a Telegram user to a per-user
OmniRoute API key (createApiKey, name telegram:<userId>) and proxies
prompts through the existing handleChat pipeline.
- src/app/api/telegram/update/route.ts — inbound endpoint serving both the
Bot API update webhook (/start + chat replies) and the Mini App direct
path (initData HMAC verified → 401 on mismatch). Public route prefix;
own auth only.
- src/app/miniapp/page.tsx — Telegram WebApp SDK chat UI.
- Tests: telegram-init-data (7), telegram-botapi (5) — 12/12 pass.
- Env docs: TELEGRAM_* vars in .env.example + ENVIRONMENT.md (sync ✓).
- Route-validation check: PASS (body validated via Zod).
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: benzntech <bensonkbmca@gmail.com>
Agent clients (OpenCode, Claude Code, Cursor) fan out heavy sub-requests
that land on the admission gate together. With the single heavyweight
slot, concurrent heavy requests were rejected immediately with a
retryable 503; clients burn their retry budget in seconds and the agent
dies mid-task.
Heavy requests now wait up to OMNIROUTE_CHAT_ADMISSION_QUEUE_MS (default
5000ms) for a slot before the 503, served FIFO; 0 restores the legacy
immediate-reject behaviour. Applied to both the byte-based path
(admitChatRequest) and the structure-based path (admitChatStructure, now
async).
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
* fix(radar): preserve extended feed fields and honor local enable override
applyFeed()'s MergedEntry shape omitted contextWindow/capabilities/limits/
setup even though FeedModel always carries them, so the dashboard's setup
link, Context column, and capability badges never rendered and the setup
page's provider lookup always failed. Both merge paths (mergeOne and
feedModelToMerged) now copy the four fields through, respecting rule 1
(local override wins) same as every other field.
feedModelToMerged() also unconditionally forced enabled:false when the feed
disabled a feed-only entry, even when the operator had locally overridden
enabled:true — mergeOne() already applies overrides after the disable rule
and got this right. feedModelToMerged() now only force-disables when there
is no local `enabled` override, matching mergeOne()'s semantics.
* fix(radar): cap feed sync response body at 10MB
syncRadar() buffered the entire feed response via
Buffer.from(await res.arrayBuffer()) with no size limit, so a
misconfigured or hostile RADAR_FEED_URL (or an upstream serving garbage)
could force an unbounded in-memory buffer. Enforcement is two-layered: a
Content-Length preflight skips reading an already-oversized body entirely,
and a running-total check while reading the stream enforces the cap even
when Content-Length is absent or understates the real size — concatenating
the accumulated chunks preserves the exact bytes the signature check needs.
Exceeding the cap returns a new { status: "too_large" } SyncStatus and
leaves the cache untouched, following the same non-destructive pattern as
every other sync failure (invalid_signature/invalid_schema/stale).
* fix(radar): gate the sidebar radar item behind RADAR_ENABLED
The "radar" sidebar item was registered unconditionally in
sidebarVisibility/sections.ts, but Sidebar.tsx has no feature-flag
awareness (it's a client component), so the link stayed visible and
clickable with RADAR_ENABLED off, landing on a 404 dashboard page.
Sidebar items gain an opt-in `featureFlagKey` field plus a pure
isSidebarItemVisibleForFlags() filter (fails open when a flag isn't in the
map, so a missing/not-yet-loaded key never hides an unrelated item). The
resolved flag value piggy-backs on the /api/settings response the sidebar
already fetches on mount (new `radarEnabled` field) rather than adding a
dedicated round trip.
* fix(radar): require auth on management routes, add GET settings
GET /api/radar/catalog, POST /api/radar/sync, and POST /api/radar/settings
had zero authentication — any client that could reach the local server
could read the merged catalog, trigger a sync, or flip the opt-in/set the
supporter key. All three (plus the new GET below) now call
isAuthenticated() from the shared apiAuth guard, same gate as the rest of
/api/settings/*. The RADAR_ENABLED flag-off 404 check keeps running FIRST
so flag-off inertia stays byte-identical (no auth prompt just to learn the
surface doesn't exist); auth runs after it, before any DB access.
Adds GET /api/radar/settings, returning { optIn, hasSupporterKey,
supporterKeyMasked } — the raw key never leaves the server on either verb.
The dashboard page's fetchSettings() now calls this endpoint instead of
inferring opt-in state from the catalog response (which always defaulted
to unknown/null), so an already-activated operator no longer sees the
activation screen on every reload. handleSync() also handles the new
too_large sync status introduced by the response-cap fix, reusing the
existing generic sync-failed copy (no new UI strings).
* docs(radar): fix stale feed URL, document tier header/auth/size cap
- RADAR_FEED_URL default was documented as radar.omniroute.dev in
ENVIRONMENT.md; the actual default (src/lib/radar/sync.ts) and every
other reference use radar.omniroute.online — fix the one stale spot.
- Correct the FREE_MODEL_BUDGETS source path: it's declared in
freeModelCatalog.data.ts, not freeModelCatalog.ts (which only
re-exports it).
- Document that the signed feed body's `tier` is always "live" (one
signed artifact per version) and the actually-served tier comes from
the `x-omniroute-feed-tier` response header, resolved with a Zod parse
+ fallback to the body field.
- Document that all four /api/radar/* routes now require auth
(isAuthenticated(), same gate as /api/settings/*), the new
GET /api/radar/settings route, and the new too_large sync status from
the 10MB response cap.
* feat(radar): daily sync scheduler + auto-sync on page open
Spec asks for a 1x/day sync while opted in and fresh data on every page
open. The scheduler only arms itself when RADAR_ENABLED AND the opt-in are
already on (boot) or right after the user opts in (settings route) — a
flag-off install never creates the timer, preserving the inertia contract.
The page auto-syncs once per mount when the cached feed is older than 6h.
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
* feat(dashboard): add RADAR_ENABLED flag (default off)
* feat(db): radar feed cache + settings with encrypted supporter key
* feat(radar): signed feed sync with pinned key and version floor
- feedSchema.ts: Zod v4 schema mirroring the server feed format
(discriminated union on budget.kind, enum constraints, etc.)
- pinnedKeys.ts: Ed25519 SPKI-DER pinned key + env override for forks
- verify.ts: signature verification over exact wire bytes, never throws
- sync.ts: full download/verify/validate/cache pipeline with injectable
deps, feature-flag gate, opt-in gate, version floor (numeric compare),
and sanitized error reasons (no stack traces)
- 40 tests covering: contract hash, key handling, sig verification,
schema validation, version compare, all sync paths (disabled, opt_out,
invalid_signature, invalid_schema, stale, updated, error), auth header
injection, and cache-untouched assertions for every failure mode
* feat(radar): read-time overlay merge rules over the free catalog
Pure function applyFeed() merges the cached Radar feed over the static
baseline catalog at read time, honoring 4 rules:
1. Feed never overwrites a local override field.
2. enabled:false disables the entry with disabledBy:"radar" provenance.
3. User-added entry NOT in the feed survives untouched.
4. User deletion tombstone prevents feed resurrection.
getRadarCatalog() accessor in index.ts: flag off / no cache / corrupt
payload all fall back to baseline. Valid cache applies the overlay and
returns feed metadata (version, tier, fetchedAt).
TDD: 19 tests (4 rules + dedup + origin + accessor flag/cache/corrupt/
valid/bad-feed + baselineToMergedEntries converter).
* feat(dashboard): radar catalog and guided setup screens
- API routes: GET /api/radar/catalog, POST /api/radar/sync, POST /api/radar/settings
- All gated on RADAR_ENABLED flag (404 when off)
- Error responses via buildErrorBody(), never raw stack/message
- Settings never echoes clear supporter key (masked omr_****<last4>)
- Sync delegates to syncRadar() server-side, never proxies feed URL
- Dashboard pages:
- /dashboard/radar: 4 states (flag off, opt-in pending, empty, populated)
- /dashboard/radar/setup?provider=X: guided setup with steps, key URL, test connection
- Uses existing Card component and next-intl patterns
- Sidebar: radar entry in costs group with icon
- i18n: pt-BR and en keys for radarPage and radarSetupPage namespaces
- Tests:
- radar-api-routes.test.ts: 11 tests (flag-off 404, flag-on shape, error sanitization)
- radar-page-state.test.ts: 5 tests (pure state logic)
- All 90 radar tests pass (including prior 74)
* docs(radar): module doc and flag-off inertia test
Add docs/frameworks/RADAR.md covering the flag gate, the separate data-sync
opt-in and privacy promise, the Ed25519 signature/pinned-key security model,
tiers, the read-time overlay merge rules, and the self-hosting env vars —
plus index entries in CLAUDE.md/AGENTS.md/docs/README.md/REPOSITORY_MAP.md.
Document RADAR_FEED_URL and RADAR_FEED_PUBKEY in .env.example and
docs/reference/ENVIRONMENT.md to satisfy check:env-doc-sync, which was
failing on this branch since the sync.ts commit added the reads.
Add tests/unit/radar-inertia.test.ts as the single canonical place asserting
the "RADAR_ENABLED off => zero behavioral delta" claim end to end: the three
/api/radar/* routes 404, the flag resolves to the definition default with no
override, getRadarCatalog() returns exactly the baseline without touching the
cache, and computeFreeModelTotals() keeps its pinned values with the Radar
module imported alongside it.
* fix(db): renumber radar migration to 135 after collision with 134
The base branch introduced 134_proxy_logs_egress_ip while this branch carried
134_radar_cache_settings; the migration runner rejects duplicate numeric prefixes.
This migration has never been applied to a real database (the PR is unmerged), so
no retroactive isSchemaAlreadyApplied guard is needed.
* i18n(radar): translate radar catalog and setup strings to all locales
The UI-coverage ratchet measures (present - placeholder) / total_en, so the
__MISSING__ sentinels that i18n:sync-ui writes do not count as covered — only
real translations restore the metric. Scoped to this PR's namespaces
(radarPage, radarSetupPage, sidebar.radar*) instead of a bulk sync, which would
have pulled ~978 unrelated pending keys into this diff.
Placeholders and code identifiers verified preserved across all 1682 strings.
* fix(radar): trust the served-tier header instead of the signed body field
The signed feed body always carries tier:"live" by design (one signed
artifact per version — rewriting the field server-side per request
would break the exact-bytes Ed25519 signature). The server now returns
the tier ACTUALLY served via the x-omniroute-feed-tier response
header, so free users on a delayed community snapshot no longer see
"Ao vivo (tempo real)" in the UI.
sync.ts now reads and validates that header (falling back to the
body's tier only when the header is absent or holds an unrecognized
value) and stores the served tier in the cache; index.ts already
surfaces cache.tier to the UI unchanged.
* test(combo): shorten an assert message that exceeded the line limit
The assertion added by #9507 was 104 chars, so prettier reformatted it into
five lines on the next commit that touched the file, pushing it past its
frozen size (3449) and failing check:file-size. The message is shortened
(the issue reference stays in the comment directly above); the assertion
itself is unchanged, and the file is back to 3448 lines and prettier-clean.
* i18n(radar): use the canonical zh-TW glossary terms
The machine translation produced retired renderings the glossary gate blocks:
供應商 for provider (canonical 提供者) and 文檔 for documentation (canonical 文件).
Fixed across the 11 affected radar strings; tests/unit/i18n-glossary-consistency-check.test.ts
is back to 17/17.
* fix(radar): point the default feed URL at the domain that exists
radar.omniroute.dev was a placeholder for a domain that was never registered,
so an out-of-the-box sync would fail DNS resolution for every user. The live
feed is served from radar.omniroute.online (the subdomain the design always
specified), now behind Cloudflare TLS. Forks still override it via
RADAR_FEED_URL.
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Root cause: a cold GET /v1/models catalog rebuild froze the entire server 41-54s.
node --prof profiling found a systemic missing-memoization pattern — a per-model
function rescanning a static or synced data structure with Object.entries()/
Object.keys() (or hitting SQLite) on every call instead of once per rebuild. Fixed
6 instances of the same pattern, found by iteratively re-profiling the full catalog
sweep after each fix (plus a whitebox review pass) until no further hotspot of this
shape remained:
1. getModelsDevPricing() (modelsDevSync.ts) — re-ran a synchronous SQLite query and
re-JSON.parse'd ~180 blobs on every call (up to ~6091x instead of once per
request). Memoized via the existing modelCatalogCacheVersion invalidation signal
(same pattern as getCachedRawProviderConnections/getCachedProviderNodes in
db/readCache.ts). Dominant cost of the original 41-54s freeze.
2. findInsensitive() (modelMetadataRegistry.ts, resolveCatalogPricing) — rebuilt a
full Object.entries() scan on every case-insensitive lookup miss, twice per
model. Replaced with a lowercase-key index built once per distinct pricing
object and cached by identity (WeakMap). Warns once at index-build time on a
case-insensitive key collision instead of silently discarding the second value.
3. getSyncedCapability() (modelsDevSync.ts) — ran a per-model SQLite SELECT on cold
cache instead of self-warming the whole-table cache; no caller in the
/v1/models build path ever primed it, so a cold rebuild ran one SQLite
round-trip per model per call site. Now self-warms via the existing bulk
getSyncedCapabilities() on first miss. Measured as the dominant remaining cost
after fixes 1-2 (~70% of a full catalog sweep).
4. getCanonicalModelSpecId() (shared/constants/modelSpecs.ts) — up to 3 separate
linear scans over the static MODEL_SPECS table per call (exact ci, alias ci,
prefix). Replaced with a lazy, lowercase-key index built once (MODEL_SPECS never
changes at runtime); prefix-match iteration order preserved exactly so
resolution outcomes are unchanged.
5. getStaticSpecCanonicalModelId() (modelCapabilities.ts) — duplicated the same
exact+alias scan as (4) in a second, separate rescan. Now reuses the shared
index via a new exported helper (findModelSpecIdByExactOrAlias) instead of
maintaining a second cache over the same static table.
reverseModelsDevProviders() (modelCapabilities.ts) — rescanned
Object.entries(MODELS_DEV_PROVIDER_MAP) (also static) on every call; memoized
by provider key. Result is frozen (readonly) since it is now shared across
calls instead of freshly allocated each time.
6. resolveModelAlias() (shared/constants/modelSpecs.ts) — rescanned
Object.entries(MODEL_SPECS) unconditionally once per model (verified 1:1 call
ratio, no short-circuit). Case-sensitive exact match (Array.includes(), no
.toLowerCase()) — uses a dedicated exact-match index, deliberately not the
case-insensitive alias index from fix 4/5 (would silently broaden matches).
Measured on a 1940-pair real-catalog sample (static PROVIDER_MODELS registry):
cold sweep 828ms -> 356ms after fixes 3-5 on top of 1-2, extrapolating to roughly
1s on the real ~6091-model catalog, down from the original 41-54s freeze.
Complementary to the stale-serve fix in #8801 (upstream) — neither alone
eliminates the freeze.
Tests: call-count regression guards for every fix (DB prepare / Object.entries /
Object.keys call counts staying constant instead of scaling with iteration count),
plus correctness coverage for case-insensitive/case-sensitive resolution. All
pre-existing consumer suites re-verified passing (96 tests total across 19 files).
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Image and video payloads vary by provider and base64 encoding adds substantial overhead. Exempt media routes from OmniRoute's global request-body cap so provider-specific validation determines whether a request is too large. Keep finite body limits for non-media routes and cover both header and streamed-body admission paths.
Modal-hosted OpenAI-compatible endpoints (self-hosted Kimi K3 via
Modal free tier) return HTTP 429 with body {"error":"usage limit
reached"} when the account's credit is exhausted. Previously no
QUOTA_PATTERNS regex matched this bare-string error shape, so the 429
fell through to rate_limit (60s short cooldown). Combined with combo
round-robin's per-conversation session stickiness (#3825), this kept
re-targeting the same exhausted connection every turn instead of
locking it out and failing over to an account with remaining credit.
Add a substring pattern matching the JSON key/value pair
"error":"usage limit reached" with tolerance for trailing
punctuation and whitespace. Only the exact "error" key matches;
different keys or qualified transient messages like "Per-minute usage
limit reached" stay classified as rate_limit.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
Adds the `?utm_source=omniroute` tracking parameter to every public-facing URL where Cheaper Inference is clickable.
- README: the two `<a href>` targets in the Open Source Friends table row (logo + "Get an API key" CTA)
- `gateways.ts`: the `website` field and the `apiHint` text
Not changed on purpose: `api.cheaperinference.com/*` endpoints (technical, not clicks), JSDoc mentions (descriptive text), and the `<sub>cheaperinference.com</sub>` label under the logo (plain text, not a link).
Registers Cheaper Inference (api.cheaperinference.com) as an OSS-sponsor gateway provider.
- Canonical provider `cheaperinference` (alias `cinf`) + routing registry with 39 measured text models
- Dedicated executor: forces `store:false` on the native /v1/responses endpoint (the shared strip in
chatCore.ts deletes `store` for every provider != openai, so without this every Responses request
400'd) and resolves chat-vs-responses URL from the per-model targetFormat
- 3 image models (grok-imagine, nano-banana-pro, nano-banana-2), prefix-only: the two nano-banana ids
already belong to adobe-firefly, which keeps the bare-id routing
- Resale pricing measured from GET /v1/models (30% off list); sponsor rail Kimi 1st / Cheaper
Inference 2nd via an explicit rank map; supporter badge in 43 locales; README row
No quota card: the gateway exposes no balance API (/v1/wallet and /v1/balance both 404).
Validated live end-to-end through OmniRoute: chat, native Responses, streaming and image generation
all 200 with real content; the Firefly collision guard verified at runtime.