Compare commits

..

43 Commits

Author SHA1 Message Date
diegosouzapw
d00a4b61f0 chore(quality): drop the now-stale set-state-in-effect suppression and rebaseline combos/page.tsx 2026-09-05 02:56:00 -03:00
diegosouzapw
7ea56e3f98 x 2026-09-05 02:55:47 -03:00
Koosha Paridehpour
3858923f68 fix(ci): ship .npmrc in published package so legacy-peer-deps applies to consumers (#11544) (#12699)
Merged — one line, zero risk, and it costs nothing to have.

One caveat recorded so nobody later reads this as "#11544 is solved": npm resolves config from the *installing* project's directory, the user config and the global config — it does not read the `.npmrc` shipped inside a dependency's tarball. So `legacy-peer-deps=true` traveling in the package will not change how `npm install -g omniroute` resolves peers on the consumer side. Our own `scripts/build/postinstall.mjs` does shell out to `npm rebuild` / `npm install better-sqlite3`, but with cwd set to `dist/`, so the package-root `.npmrc` is not in scope there either.

Keeping it anyway: it makes the published tree self-documenting, and someone debugging inside an extracted package gets the same retry budget we use in CI. But #11544 (`npm install -g omniroute` failing on Windows, "root cause unclear from log") still needs the actual `npm-debug.log` from the reporter before it can be closed.

Rebased onto `release/v3.8.51`; `package.json` re-parses and the `files` array kept both `config/i18n.json` and the new entry. Thanks.
2026-09-05 02:34:41 -03:00
Koosha Paridehpour
8c4fb8faf2 chore(deps): pin browserslist override to ^4.28.8 (#12592)
Merged. One line in `overrides`, low blast radius, and pinning a transitive that every build tool reads is defensible on its own.

Validated on `release/v3.8.51`: `package.json` re-parses, `typecheck:core` clean, `check-file-size` OK.

For future dependency pins, a line in the body about what the floating range actually broke (a specific build failure, a CVE, a resolution conflict) makes these reviewable without guessing. Thanks.
2026-09-05 02:34:23 -03:00
Koosha Paridehpour
7da6e10c4e fix(docker): pin 4 CLI tools to exact versions (#12576) (#12703)
Merged. Verified all four pins resolve on npm before landing:

```
@openai/codex@0.153.2              0.153.2
@anthropic-ai/claude-code@2.1.260  2.1.260
droid@0.212.0                      0.212.0
openclaw@2026.9.1                  2026.9.1
```

The reproducibility argument holds — a floating `@latest` in a cached Docker layer means two builds of the same commit can ship different toolchains, and that is exactly the class of drift that makes a CI failure unattributable.

Worth flagging for whoever maintains this next: pinning trades drift for staleness, so these four now need a periodic bump or the image ships increasingly old CLIs. The comment block you added explains the why, which makes that bump a safe mechanical change instead of a judgment call.

Rebased onto `release/v3.8.51` (the PR was cut from `main`, ~3695 commits behind). Thanks.
2026-09-05 02:34:20 -03:00
Koosha Paridehpour
0df5be5b09 docs(gamification): align XP Rewards table with code (#12501) (#12667)
Merged, with the markdown repaired.

Checked every row against `src/lib/gamification/xp.ts:138` — the table now matches `XP_REWARDS` exactly, keys and values, and the descriptions are the JSDoc lines verbatim. The old table was documenting actions that do not exist (`badge_earned`, `streak_milestone`, `referral`, `model_diversity`, `compression_use`, `skill_use`) and missing the three that do (`model_switch`, `invite_redeem`, `streak_bonus`). Good catch.

Two formatting fixes before merge: the action names were padded inside the code spans (`` `request          ` ``), which renders the trailing spaces as part of the identifier; and the unrelated MCP-tools table below had its header row flattened, losing the column alignment. Restored both and ran Prettier — the file is clean now.

Thank you for reconciling this against the source instead of guessing.
2026-09-05 02:34:17 -03:00
Koosha Paridehpour
f40c77e837 fix(docker): document and harden cli profile trust boundary (#12570) (#12706)
Merged, with the threat model kept and two unverifiable claims dropped.

The core warning is correct and worth having in both files: `/var/run/docker.sock` is a host-root trust boundary, the `cli` profile must not be published beyond `127.0.0.1`, and no extra host mounts belong in it. That is now in `docker-compose.yml` next to the mount and in the DOCKER_GUIDE.

Two things I changed before merging, both `AGENTS.md` documentation-accuracy calls:

1. **The stated purpose.** The socket is not mounted so OmniRoute can "launch short-lived codex/claude-code/droid/openclaw containers" — I could not find any container-spawn path. It is there for the in-container auto-updater: `src/lib/system/autoUpdate.ts:236` probes for `/var/run/docker.sock` and skips the Docker path when it is absent, and the mount sits right beside `AUTO_UPDATE_HOST_REPO_DIR`. Rewrote the sentence around that and cited the file.
2. **Item 3, the audit log.** "recorded in the server log with the called tool, the prompt digest (not content), and the spawned image SHA" — no such logging exists (`grep -rn "prompt digest\|promptDigest\|imageSha" src/ open-sse/` is empty). A security doc promising forensics that are not implemented is worse than one that stays quiet, so I removed the item rather than soften it.

The `MITM-TPROXY-DECRYPT.md` and `SUPPLY_CHAIN.md` cross-references both resolve and stayed.

Thanks — the docker.sock boundary genuinely was undocumented.
2026-09-05 02:33:59 -03:00
Koosha Paridehpour
c5d47dad8a docs(security): document socket.yml scanner config + CI workflow link (#12575) (#12764)
Merged, with one sentence removed.

The `socket.yml` half checks out: the file exists at the repo root, is `version: 2`, and its `projectIgnorePaths` really do list `tests/`, `_tasks/`, `_references/`, `_ideia/`, `_mono_repo/`, `docs/` — so the paragraph describes the config accurately.

The closing sentence did not: there is no `.github/workflows/socket-dev.yml` in this repo (`ls .github/workflows | grep -i socket` is empty), and nothing auto-opens `supply-chain-review/` issues. Per the documentation-accuracy rule in `AGENTS.md` — every path and workflow named in docs has to survive an `rg`/`ls` — I replaced it with what is actually true: the scan is driven by the Socket GitHub App reading `socket.yml`, not by a workflow here.

Everything else merged as written. Thanks — pointing readers of SECURITY.md at the scanner config was a real gap.
2026-09-05 02:33:56 -03:00
Koosha Paridehpour
366099a08c fix(i18n): quote <name> placeholder in OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES description (#12505) (#12769)
Merged, with the fix moved to where the bug actually lives — and thank you, because the issue analysis in #12505 is what made that possible.

The diagnosis was right: `FeatureFlagsGrid.tsx:422-428` renders descriptions through a plain `t()`, so next-intl compiles the value as ICU and a bare `<name>` parses as an unknown rich-text tag. But the branch changed `src/shared/constants/featureFlagDefinitions.ts` — the TS default, which is the `flag.description` **fallback** rendered raw, never through ICU. Two consequences: the reported bug stayed live (all 42 locale files still carried the raw tag — `grep -l "profiles/<name>/settings.json" src/i18n/messages/*.json` returned 42, and 0 for the escaped form), and the quotes would have shown up literally in the one place that string does render.

So this merge reverts the TS default to the raw path and applies the ICU escape to the 42 locale files instead — follow-up 1 from your issue, inverted to hit the file that matters.

I also added follow-up 2 as a real guard: `tests/unit/feature-flag-description-icu-parse-12505.test.ts` compiles every `featureFlags.definitions.*` message in every locale through `intl-messageformat` (the parser next-intl uses) and asserts the placeholder renders as a literal `<name>`. Verified red-then-green — reverting `en.json` alone fails both cases; restored, 2/2 pass.

Validated on `release/v3.8.51`: all locale files re-parse as valid JSON, `typecheck:core` clean, `check-file-size` OK. `i18n:check` drift is pre-existing on the tip, unrelated.

Closes #12505.
2026-09-05 02:33:37 -03:00
Koosha Paridehpour
82f78b3b3b fix(api/pricing): surface validation error message as string, not raw object (#12494) (#12771)
Merged, with one adjustment.

Confirmed the bug end to end: `PricingTab.tsx:369` types the payload as `{ error?: string }` and feeds it to `new Error(errorPayload.error || ...)`, so the `{ message, details }` object landed in the toast as `[object Object]` — exactly what #12494 reported.

The one change I made before merging: `validation.error.message` is the fixed constant `"Invalid request"` (see `validateBody` in `src/shared/validation/helpers.ts:44`), so it would have swapped an unreadable toast for an uninformative one. The repo already has `formatValidationMessage()`, added in #10849 for precisely this case — it returns `"field: reason"` naming the first offending field. Merged with that instead, so a bad pricing value now says which field it was.

Validated on `release/v3.8.51`: `typecheck:core` clean, `check-file-size` OK. Rebased onto the release branch — the PR was cut from `main`, which is ~3695 commits behind the active branch.

Thank you for the report and the fix.
2026-09-05 02:33:33 -03:00
Koosha Paridehpour
891cb26b2c fix(db): back-fill last_ping_at + last_pinged_reset_key on provider_connections (#12470)
Merged. Clean, surgical fix with its own regression guard.

`ensureProviderConnectionsColumns()` reconciles the base columns that later data migrations assume, but `last_ping_at` / `last_pinged_reset_key` were only ever created by `123_quota_auto_ping` — so a lineage that skipped it kept a table that the quota auto-ping writes cannot target. Adding them to the reconciliation list is exactly the right place.

Validated on `release/v3.8.51`: `tests/unit/db-schema-columns-split.test.ts` 10/10, including your new `back-fills last_ping columns on a pre-123 lineage` case and the idempotency re-run. `typecheck:core` clean, `check-file-size` OK. The `changelog.d/fixes/` fragment was already correct.

Thank you — this is the shape a fix should have: root cause named, minimal diff, test that fails without it.
2026-09-05 02:33:16 -03:00
Diego Rodrigues de Sa e Souza
c3945a724c fix(ci): security-tier gate must honor ALWAYS_PROTECTED_API_PATTERNS too (+ file-size rebaseline) (#12605)
* fix(ci): mirror isLocalOnlyPath in the security-tier gate and rebaseline four merged-growth file caps

Two base-reds on release/v3.8.51 (#12581), both drained at the source.

1) check:openapi-security-tiers reported six CORRECTLY annotated routes as
   unprotected and demanded the removal of their x-loopback-only annotation —
   pushing the fix in the unsafe direction. The gate re-reads routeGuard.ts as
   text (it cannot import the module: routeGuard pulls the server runtime and
   the gate runs on plain node), but it only read the FIRST half of
   isLocalOnlyPath():

     LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...)

   so every route gated by a regex (/api/providers/volcengine-plan/connect/*)
   or by an imported constant (VNC_ROUTE_PREFIX, which the text parse turned
   into the literal string "VNC_ROUTE_PREFIX") looked open. Proven with
   isLocalOnlyPath() at runtime: all six return true; the control
   /api/providers/{id}/refresh stays false.

   New scripts/check/routeGuardConstants.mjs reads BOTH arrays, resolves
   imported identifiers by following the import, and THROWS on an unresolvable
   token instead of silently degrading it into a literal. Its array scanner is
   hand-rolled because regex literals carry the brackets and commas a
   \[([^\]]+)\] capture plus a naive comma split break on ([^/] and {1,3}).
   The reverse pass (missing-annotation warnings) now uses the same predicate.

2) check:file-size: four frozen files grew past their cap through merged PRs —
   chat.ts +10 (#12427/#12503 video-transcript redaction, derived from the
   post-guardrail payload at the single dispatch point) and stream.ts /
   accountFallback.ts / codex.ts +17 total (#12179 hot-path regex hoisting,
   bounded caches, quadratic-buffering fix). All cohesive at existing
   chokepoints; rebaselined with the rationale recorded in the baseline file.

Refs #12581

* fix(ci): security-tier gate must honor ALWAYS_PROTECTED_API_PATTERNS too

#12350 fixed the LOCAL_ONLY half of the checker (prefixes + patterns +
imported consts). isAlwaysProtectedPath() is two-armed the same way:

  ALWAYS_PROTECTED_API_PATHS.some(...) || ALWAYS_PROTECTED_API_PATTERNS.some(...)

but the checker still read only the path array, so the four credential
routes gated by the GHSA-5926-2w35-7h4q pattern (#12600) —
/api/providers/{id}/{claude,codex}-auth/{export,apply-local} — reported as
'has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS', asking for
the removal of a CORRECT annotation on a credential-export route.

Verified with the real predicate: all four isAlwaysProtectedPath() → true;
control /api/providers/{id}/models → false.

tests/unit/openapi-security-tiers.test.ts already checks BOTH arrays (#12600
updated the test but not the gate script) and stays green — this commit makes
the gate agree with the test and with the runtime.

Also carries the file-size rebaseline for four caps grown by merged PRs
(chat.ts +10 from #12427/#12503; stream.ts / accountFallback.ts / codex.ts
+17 from #12179), rationale recorded in the baseline file.

Refs #12581

* fix(ci): re-anchor the zcodeProtocol public-creds allowlist entry (302 -> 313)

The check:public-creds allowlist pins each frozen literal by FILE:LINE, so
#12179 (hot-path regex hoisting in the same file) shifted the ZCode handshake
id from L302 to L313 and broke the gate twice over: the old entry went stale
('a violação foi corrigida; REMOVA a entrada') while the literal itself, now
at L313, was no longer covered.

The literal is unchanged and still not a credential: `omniroute-${process.pid}`
is a per-process handshake id for the local ZCode app-server, already audited
and frozen with that justification. Only the anchor moves.

Refs #12581

* test(ci): re-anchor the ZCode allowlist test to L313 alongside the gate entry

The allowlist key is file:LINE:value, so the synthetic source in this test
pads to the exact line the entry pins. Re-anchoring the entry 302 -> 313
(previous commit) without moving the padding left the test asserting the old
line — caught by Unit Tests fast-path (4/4) on #12605.

Both halves now sit at 313, and the test still proves the allowlist does NOT
weaken detection: swapping the value for 'upstream-client-' is still flagged.

Refs #12581

* docs(ci): changelog fragment for #12605

* chore(ci): trim #12605 to the one fix the base still needs

The base drained fast while this PR was open. Re-verified on 008da6d19a and
dropped everything already covered there:

- check-public-creds.mjs: the base already re-anchors the ZCode entry to L313
  (my commit only added a comment on top) -> reverted to the base version.
- file-size-baseline.json: the base rebaselined chat.ts/codex.ts/
  accountFallback.ts to HIGHER caps than mine, and stream.ts measures 3064
  against the base cap of 3072 — my 3078 bump would have loosened a cap for
  no reason -> reverted to the base version.

What the base still does NOT have, verified on its current tip:
  node scripts/check/check-openapi-security-tiers.mjs -> EXIT=1, 4 mismatches
so the ALWAYS_PROTECTED_API_PATTERNS half stays, plus its changelog entry.

Refs #12581
2026-09-04 04:07:43 -03:00
Markus Hartung
008da6d19a feat(dashboard): link a log entry's Conversation Context to its owning conversation (#12646)
Validado sobre o tip de `release/v3.8.51`, com duas coisas resolvidas antes do merge.

**A falha de CI era stale.** O job `No new ESLint warnings` deste PR apontava `react-hooks/set-state-in-effect` em `src/app/(dashboard)/dashboard/combos/page.tsx:774` — arquivo que este PR não toca, e o mesmo erro aparecia em #12668 e #12672, que também não o tocam. A linha do tempo: o #12355 introduziu a violação de manhã, os CIs rodaram nessa janela, e o #12607 acrescentou a entrada de supressão à tarde. Medido no tip atual com o comando exato do job: **0 ocorrências não suprimidas**. A supressão sobrevivente é "unpruned", e o script passa `--pass-on-unpruned-suppressions` justamente para isso não bloquear.

**Faltava o teste que a regra do projeto exige** para mudanças em `src/`. Acrescentei `tests/unit/ui/log-detail-conversation-link-12646.test.tsx`, verificado **RED-then-GREEN** em vez de escrito contra o código pronto: revertendo `RequestLoggerDetail.sections.tsx` para o tip, 2 dos 3 casos falham; com a mudança deste PR, 3/3 passam.

Detalhe que valeu a pena descobrir: a seção curto-circuita em `allTurns.length === 0`, então o fixture precisa de um `requestBody` que normalize em pelo menos um turno — sem isso o cabeçalho inteiro nunca monta e as asserções passariam pelo motivo errado. O teste fixa três coisas: o href para um `sessionTag` simples, o percent-encoding para um que não é URL-safe, e a ausência de link quando não há `sessionTag`.

Obrigado, @hartmark.
2026-09-04 03:39:09 -03:00
Diego Rodrigues de Sa e Souza
488f57e9d3 feat(catalog): eligibility-gated free-tier bucket (#12669)
* test(catalog): pin the 2026-09-02 free-tier re-audit facts for gemini, ollama-cloud, groq, nara and mistral

* fix(catalog): re-audit gemini, ollama-cloud, groq, nara and mistral against official pages

* fix(catalog): restore the console-verified Mistral 1B pool and harden its regression test

* docs(free-tiers): move headline to the re-audited ~1.50B and refresh pool counts

* chore(free-tiers): retire stale Groq free-tier text and preset model; fix catalog header

* feat(catalog): eligibilityGate field and gatedRecurringTokens total

* docs(free-tiers): state the evidence-comment rule honestly and retire the last "14.4K RPD" Groq texts

* feat(check): docs-counts gate validates the eligibility-gated free-tier figure

* fix(docs): budget card reads computeFreeModelTotals() instead of regex-parsing the catalog

* docs(free-tiers): retire the stale Gemini onboarding quota text

* feat(radar): carry eligibilityGate through the feed schema, the merge and the summary API

* feat(dashboard): show the eligibility-gated free-tier figure apart from the headline

* docs(free-tier): refresh catalog-entry counts to 442 after base sync

* feat(catalog): ModelScope as the first eligibility-gated pool; document the gated bucket and how we count

* docs(free-tiers): restore README spacing lost in the merge and re-sync the guide counts

* docs(free-tiers): correct the unsummed-catalog comparison figure to the current catalog

* docs(free-tiers): re-sync numbers after merging release/v3.8.51 (Cerebras reclassified upstream)

* docs(free-tiers): re-sync numbers after merging PR1 (Cerebras reclassified upstream)

* fix(docs): keep the NaraRouter plans endpoint out of the API-path checker; rebaseline gateways.ts (+3)

* fix(catalog): keep eligibility-gated rows out of every headline-adjacent figure

The eligibility gate was honored by the steady headline and the pool count, but
three adjacent figures still counted gated rows: the credit reductions feeding
steadyWithRecurringCreditsTokens/firstMonthRealisticTokens, the uncappedProviders
list ("permanently free, no cap"), and the docs gate's free-forever provider set,
which was built from freeType alone.

- computeFreeModelTotals: filter !isGated in the recurring-credit, one-time-credit
  and uncapped predicates; gatedProviders semantics unchanged (steady rows only).
- check-docs-counts-sync: exclude eligibility-gated rows from the FOREVER set,
  which moves the live free-forever count 53 -> 52 (the base's value). README,
  promise-pillars.svg and FREE-TIERS-GUIDE re-synced.
- gen-budget-card-svg: skip gated one-time credits like the totals do, and fail
  loudly on `--out` without a path.
- Tests: gated one-time credit does not move firstMonthRealisticTokens; a gated
  uncapped row is not in uncappedProviders; shipped gated rows carry no credit
  tokens; the committed budget card is byte-identical to a fresh generation.

* test(catalog): allow eligibilityGate in the no-per-row-rating key allowlist

The allowlist landed on the base with #12318, after this branch's field was designed;
eligibilityGate says who may claim a quota, not how much a row can be trusted.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw24@gmail.com>
2026-09-04 00:45:38 -03:00
Diego Rodrigues de Sa e Souza
c41ec7f862 chore(quality): rebaseline chat.ts for #12641's effective-input persistence (#12680)
Rebaseline medido no tip com o #12641 mergeado.
2026-09-04 00:04:36 -03:00
Markus Hartung
6ff7b26277 fix(dashboard): keep a request's pending-tracking id stable across combo target retries (#12650)
Validado em lote numa worktree combinada com os 3 PRs desta leva sobre o tip de `release/v3.8.51`: os três boardaram sem conflito, `typecheck:core` limpo e **22/22** nos arquivos de teste que trazem.

O crescimento de `src/sse/handlers/chat.ts` (2450 → 2454) é do #12641 e vai num PR de rebaseline próprio.

Obrigado, @hartmark.
2026-09-04 00:03:34 -03:00
Markus Hartung
74c2d26393 fix(responses-continuation): chain off the effective post-reconstruction input, not the pre-reconstruction client bytes (#12641)
Validado em lote numa worktree combinada com os 3 PRs desta leva sobre o tip de `release/v3.8.51`: os três boardaram sem conflito, `typecheck:core` limpo e **22/22** nos arquivos de teste que trazem.

O crescimento de `src/sse/handlers/chat.ts` (2450 → 2454) é do #12641 e vai num PR de rebaseline próprio.

Obrigado, @hartmark.
2026-09-04 00:03:16 -03:00
Diego Rodrigues de Sa e Souza
f8a0f9c1f8 chore(quality): rebaseline combo.ts for the stacked reset-aware scoring (#12678)
Rebaseline medido no tip com os 4 PRs da leva mergeados.
2026-09-03 23:48:48 -03:00
Bob.Hou
2a6eff0aec fix(combo): keep Antigravity Gemini usable when Claude weekly is empty (#12637)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:47:28 -03:00
Bob.Hou
d36d077a4d fix(resilience): keep Overloaded STREAM_EARLY_EOF off the provider breaker (#12626)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:45:47 -03:00
Bob.Hou
36be267a17 fix(providers): add CLAUDE_CODE_CLIENT_VERSION and GITHUB_COPILOT_CLI_VERSION env overrides (#12632)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:43:38 -03:00
Bob.Hou
57d7c8bc88 fix(providers): sanitize boolean required and nested bare maps for Gemini (#12269) (#12624)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:42:40 -03:00
Ravi Tharuma
3b7c541f72 feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode payloads (#12636)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:42:09 -03:00
Ravi Tharuma
5f9c358e9b fix(monitoring): serve cached credentialHealth off the request path (#12533)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:40:22 -03:00
Ravi Tharuma
ac94dd9bcf docs(arch): one-process recipe for tens of long /v1/responses (#12493)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:40:03 -03:00
Ravi Tharuma
6aa3690dea feat(providers): filter GitHub combo members against live catalog (#12473)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:39:43 -03:00
Ravi Tharuma
85b8d128eb fix(auth): do not park healthy quota accounts as expired (#12452)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:39:22 -03:00
Ravi Tharuma
8c1dfc416d fix(combo): do not treat credits-exhausted 401 as auth skip (#12449)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:57 -03:00
Ravi Tharuma
3d2bcc9f12 feat(api): emit gateway-measured tokens-per-second excluding TTFT (#12631)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:37 -03:00
Ravi Tharuma
8c8d23a98f fix(api): bound hung GET /v1/models catalog rebuilds (#12628)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:20 -03:00
Ravi Tharuma
b0557543b8 fix(opencode-plugin): lengthen /v1/models timeout and attach HTTP status (#12607)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:04 -03:00
Ravi Tharuma
bb8e75a00d fix(resilience): surface Responses failed.error.message in 502s (#12472)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:37:47 -03:00
Diego Rodrigues de Sa e Souza
04ba19fa62 chore(quality): rebaseline apiKeys.ts for #12352's preserved ACL (#12673)
Rebaseline medido no tip com o #12352 mergeado. Única violação de file-size do tip e inteiramente crescimento próprio daquele PR.
2026-09-03 23:29:56 -03:00
Goni Sulaiman
57d9357d88 fix(i18n): wrap ccOnboardingKeyPlaceholder in ICU single quotes across all 43 locales (#12369)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:28:33 -03:00
Goni Sulaiman
6e35ad01cc fix(cli): remove duplicate positional argument in tunnel create command (#12368)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:28:12 -03:00
Goni Sulaiman
9cbc4f118e fix(models): publish effort_tiers on Kimi K3 base models only (#12299) (#12371)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:27:55 -03:00
Krzysztof Skomra
9271a34ec1 fix(api): preserve API key ACL on creation (#12352)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:27:33 -03:00
Krzysztof Skomra
11e1c79e65 fix(combos): clear LKGP pins on delete (#12425)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:27:15 -03:00
Krzysztof Skomra
d6771779f7 fix(cli): preserve Claude settings on config set (#12432)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:26:59 -03:00
Diego Rodrigues de Sa e Souza
16b0d4e3ad fix(catalog): re-audit free-tier quotas against official pages (#12649)
* test(catalog): pin the 2026-09-02 free-tier re-audit facts for gemini, ollama-cloud, groq, nara and mistral

* fix(catalog): re-audit gemini, ollama-cloud, groq, nara and mistral against official pages

* fix(catalog): restore the console-verified Mistral 1B pool and harden its regression test

* docs(free-tiers): move headline to the re-audited ~1.50B and refresh pool counts

* chore(free-tiers): retire stale Groq free-tier text and preset model; fix catalog header

* docs(free-tiers): state the evidence-comment rule honestly and retire the last "14.4K RPD" Groq texts

* docs(free-tiers): retire the stale Gemini onboarding quota text

* docs(free-tier): refresh catalog-entry counts to 442 after base sync

* docs(free-tiers): restore README spacing lost in the merge and re-sync the guide counts

* docs(free-tiers): re-sync numbers after merging release/v3.8.51 (Cerebras reclassified upstream)

* fix(docs): keep the NaraRouter plans endpoint out of the API-path checker; rebaseline gateways.ts (+3)

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-09-03 23:26:32 -03:00
diegosouzapw
2505a5b5c9 fix(dashboard): read the combos usage-guide dismissal from an external store (base-red #12581)
`release/v3.8.51` is red on `ESLint errors: 1 error(s)`:

  src/app/(dashboard)/dashboard/combos/page.tsx:774
  error react-hooks/set-state-in-effect — Calling setState synchronously
  within an effect can trigger cascading renders

The pattern was deliberate and the comment above it explains why: the
dismissal lives in localStorage, SSR cannot read it, and a lazy useState
initializer would hydrate with a mismatch. The effect fixed the mismatch
at the cost of an extra commit of the whole page tree on every load —
which is exactly what the rule (new in eslint-plugin-react-hooks 7.1.1,
the version this branch pins) now rejects.

`useSyncExternalStore` is the sanctioned shape for this: getServerSnapshot
supplies the SSR-safe default, getSnapshot reads localStorage after
hydration, and the two persistence handlers notify subscribers instead of
setting state. Subscribing to `storage` keeps other tabs in sync for free.

Behavior is preserved exactly, including the distinction between the two
dismissals: "hide forever" persists, while plain "hide" stays per-mount
and is kept as local state rather than folded into the store.

Validated: the rule reproduces locally with the pinned 7.1.1 plugin
(1 error) and is clean after the change; `typecheck:core` 0 errors.
Note `tests/unit/ui/combos-page-smoke.test.tsx` is quarantined in
vitest.config.ts — run under a non-excluded name it times out at 5000ms
importing the module, identically on the unmodified base file, so that
failure is pre-existing and unrelated.
2026-09-03 23:24:15 -03:00
Diego Rodrigues de Sa e Souza
2265ce761f fix(security): harden public error boundaries (#12506)
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura.

**A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos.

A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis).

**A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura.

Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
2026-09-03 21:31:13 -03:00
Bob.Hou
109cf0f26c fix(providers): reclassify Cerebras as a one-time $5 signup credit (#12591)
Validado sobre o tip de release/v3.8.51 — e o PR ficou completo depois que o autor adicionou a tabela de preços.

O que fazia o teste falhar antes não era a lista free (que o PR já tinha corrigido em `LEGACY_FREE_PROVIDERS` e `tierDefaults.json`), e sim que `classifyTier` cai no ramo cost-based e todos os modelos Cerebras estavam declarados com `input: 0, output: 0` — $0/M ≤ threshold devolve 'free' de qualquer jeito. A tabela de preços resolve isso na raiz.

Confirmei o `gpt-oss-120b` a $0,35/$0,75 de forma independente contra a fonte pública, o que corrobora o resto da tabela. **4/4** no teste-guarda e **25/25** somando free-tier-catalog e free-models; typecheck:core limpo. Obrigado, @HouMinXi.
2026-09-03 21:30:32 -03:00
228 changed files with 6681 additions and 1115 deletions

View File

@@ -418,7 +418,9 @@ ALLOW_API_KEY_REVEAL=false
# provider dispatch. Heavyweight capacity is reserved before parsing; excess work
# receives 503 + Retry-After instead of overlapping until the process OOMs.
# Used by: src/shared/middleware/chatBodyAdmission.ts
# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB).
# Actual bodies at or above this size take the heavyweight lease (BYTE path,
# including POST /v1/responses) and use the same #10437 healthy-headroom escape
# as structure-heavy. Default 262144 (256 KB).
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
# Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB).
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
@@ -426,6 +428,11 @@ ALLOW_API_KEY_REVEAL=false
# left unset, heavyweight admission is gated by OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES below
# instead (an auto-derived byte budget), fixing coding-agent fan-out (multiple
# subagents/CLIs) collapsing to an effective concurrency of ~1 and 503ing.
# Two overlapping ~750k-token /v1/responses abort ~12 Gi heaps (#7849) — a
# memory-budget warning, not a hard product max of 2. A healthy heap may admit
# more via HEALTHY_HEADROOM. Tens of long SSE clients (40-50) is heap +
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES / #10110. Multiply heaps with N independent
# DATA_DIRs (#11024); never replicas>1 on one SQLite.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Override for the auto-derived ingest byte budget (#503-fanout). Default: 25% of the
# process's effective memory ceiling (V8 heap limit, or the tighter cgroup/container
@@ -433,13 +440,15 @@ ALLOW_API_KEY_REVEAL=false
# 2 GiB; explicit overrides are clamped to the same safe range. Read
# chatAdmission.maxInflightBytes/budgetSource at /api/monitoring/health before overriding.
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES=134217728
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for BYTE and STRUCTURE
# heavyweight admission (#10183, #10268, #10437): a concurrent heavyweight request
# past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT is only shed with a retryable 503 when the
# heap is ALSO under this much pressure — on a healthy heap it is admitted via
# healthy-headroom instead. Range (0, 1]. Default 0.75.
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
# (#10437, BYTE + STRUCTURE, including bodies >= OMNIROUTE_CHAT_LARGE_BODY_BYTES):
# once this many concurrent leases are active through the healthy-heap bypass,
# further busy requests fall through to the same bounded-wait/shed path used under real heap
# pressure. 0 disables the bypass entirely. Default 1.
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
@@ -1323,6 +1332,16 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.144.1
#
# Override the advertised Claude Code client version independently of
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
# value; a UA-only override is not enough (#12417). Used by:
# src/shared/constants/claudeCodeClient.ts.
# CLAUDE_CODE_CLIENT_VERSION=2.1.259
#
# Override the advertised GitHub Copilot CLI version independently of
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
# GITHUB_COPILOT_CLI_VERSION=1.0.82
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
# from the Codex Responses stream. These frames break the OpenAI SDK's
@@ -1914,6 +1933,11 @@ APP_LOG_TO_FILE=true
# Default: true
# MODEL_CATALOG_INCLUDE_NAMES=true
# Cold-path wait bound for a coalesced GET /v1/models catalog rebuild (#12627).
# Used by: src/app/api/v1/models/catalogCache.ts
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
# CATALOG_BUILD_TIMEOUT_MS=8000
# ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -75,6 +75,7 @@ import {
AUTO_VARIANT_DESCRIPTIONS,
type FreeModelFreeType,
} from "./naming.js";
import { applyOmniRouteInferenceTelemetry } from "./telemetry.js";
/**
* Minimal leveled logger sink accepted by the default fetchers and the static
@@ -1199,7 +1200,7 @@ export type OmniRouteModelsFetcher = (
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000
timeoutMs = 30_000
) => {
if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models");
if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models");
@@ -1221,9 +1222,12 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
signal: controller.signal,
});
if (!res.ok) {
throw new Error(
const err = new Error(
`@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
);
) as Error & { statusCode: number; status: number };
err.statusCode = res.status;
err.status = res.status;
throw err;
}
const body = (await res.json()) as unknown;
const rawList: unknown[] = Array.isArray(body)
@@ -3766,6 +3770,8 @@ export function createOmniRouteFetchInterceptor(config: {
baseOrigin = baseUrl.origin;
const basePath = ensureV1Suffix(baseUrl.pathname);
inferencePaths.add(`${basePath}/chat/completions`);
inferencePaths.add(`${basePath}/responses`);
inferencePaths.add(`${basePath}/messages`);
inferencePaths.add(`${basePath}/models`);
} catch {
// Credential-attached base URLs are not schema-validated. A malformed
@@ -3809,7 +3815,7 @@ export function createOmniRouteFetchInterceptor(config: {
headers.set("Content-Type", "application/json");
}
return fetch(input, { ...init, headers });
return applyOmniRouteInferenceTelemetry(await fetch(input, { ...init, headers }));
};
}
@@ -5398,7 +5404,7 @@ export function createOmniRouteConfigHook(
// exact warn message so per-endpoint fallbacks are preserved.
const doModels = async (): Promise<void> => {
try {
localRawModels = await fetcher(baseURL, apiKey, 10_000);
localRawModels = await fetcher(baseURL, apiKey, 30_000);
} catch (err) {
logAt(
"error",

View File

@@ -0,0 +1,249 @@
/**
* Map gateway-reported OmniRoute inference telemetry onto the JSON/SSE
* payload OpenCode already consumes. Prefer headers / usage fields from the
* gateway. Never invent tok/s from tokens / latency (that includes TTFT).
*/
export type OmniRouteInferenceTelemetry = {
costUsd?: number;
tokensIn?: number;
tokensOut?: number;
tokensPerSecond?: number;
ttftMs?: number;
latencyMs?: number;
model?: string;
provider?: string;
};
const HEADER = {
cost: "x-omniroute-response-cost",
tokensIn: "x-omniroute-tokens-in",
tokensOut: "x-omniroute-tokens-out",
tokensPerSecond: "x-omniroute-tokens-per-second",
ttftMs: "x-omniroute-ttft-ms",
latencyMs: "x-omniroute-latency-ms",
model: "x-omniroute-model",
provider: "x-omniroute-provider",
} as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readFiniteNumber(raw: string | null): number | undefined {
if (raw == null) return undefined;
const trimmed = raw.trim();
if (trimmed === "") return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
function readPositiveNumber(raw: string | null): number | undefined {
const parsed = readFiniteNumber(raw);
if (parsed === undefined || parsed <= 0) return undefined;
return parsed;
}
function readNonNegativeInt(raw: string | null): number | undefined {
const parsed = readFiniteNumber(raw);
if (parsed === undefined || parsed < 0) return undefined;
return Math.round(parsed);
}
function readToken(raw: string | null): string | undefined {
if (raw == null) return undefined;
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
}
export function parseOmniRouteInferenceTelemetry(headers: Headers): OmniRouteInferenceTelemetry {
const out: OmniRouteInferenceTelemetry = {};
const cost = readFiniteNumber(headers.get(HEADER.cost));
if (cost !== undefined && cost >= 0) out.costUsd = cost;
const tokensIn = readNonNegativeInt(headers.get(HEADER.tokensIn));
if (tokensIn !== undefined) out.tokensIn = tokensIn;
const tokensOut = readNonNegativeInt(headers.get(HEADER.tokensOut));
if (tokensOut !== undefined) out.tokensOut = tokensOut;
const tps = readPositiveNumber(headers.get(HEADER.tokensPerSecond));
if (tps !== undefined) out.tokensPerSecond = tps;
const ttft = readPositiveNumber(headers.get(HEADER.ttftMs));
if (ttft !== undefined) out.ttftMs = ttft;
const latency = readPositiveNumber(headers.get(HEADER.latencyMs));
if (latency !== undefined) out.latencyMs = latency;
const model = readToken(headers.get(HEADER.model));
if (model) out.model = model;
const provider = readToken(headers.get(HEADER.provider));
if (provider) out.provider = provider;
return out;
}
function telemetryFromUsage(usage: Record<string, unknown>): OmniRouteInferenceTelemetry {
const out: OmniRouteInferenceTelemetry = {};
const tps = usage.tokens_per_second;
if (typeof tps === "number" && Number.isFinite(tps) && tps > 0) {
out.tokensPerSecond = tps;
}
const ttft = usage.ttft_ms;
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft > 0) {
out.ttftMs = ttft;
}
return out;
}
function mergeTelemetry(
base: OmniRouteInferenceTelemetry,
extra: OmniRouteInferenceTelemetry,
): OmniRouteInferenceTelemetry {
return {
...base,
...Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined)),
};
}
function isInferencePayload(payload: Record<string, unknown>): boolean {
return (
isRecord(payload.usage) ||
Array.isArray(payload.choices) ||
payload.object === "chat.completion" ||
payload.object === "response" ||
payload.type === "message" ||
Array.isArray(payload.output)
);
}
function attachToUsage(
usage: Record<string, unknown>,
telemetry: OmniRouteInferenceTelemetry,
): Record<string, unknown> {
const next = { ...usage };
if (
telemetry.tokensPerSecond !== undefined &&
(typeof next.tokens_per_second !== "number" || next.tokens_per_second <= 0)
) {
next.tokens_per_second = telemetry.tokensPerSecond;
}
if (telemetry.ttftMs !== undefined && (typeof next.ttft_ms !== "number" || next.ttft_ms <= 0)) {
next.ttft_ms = telemetry.ttftMs;
}
if (telemetry.costUsd !== undefined && typeof next.cost !== "number") {
next.cost = telemetry.costUsd;
}
return next;
}
export function attachOmniRouteTelemetryToPayload(
payload: unknown,
telemetry: OmniRouteInferenceTelemetry,
): unknown {
if (!isRecord(payload) || !isInferencePayload(payload)) {
return payload;
}
const next: Record<string, unknown> = { ...payload };
if (telemetry.model) {
next.model = telemetry.model;
}
if (isRecord(next.usage)) {
next.usage = attachToUsage(next.usage, mergeTelemetry(telemetry, telemetryFromUsage(next.usage)));
}
if (isRecord(next.response) && isRecord(next.response.usage)) {
next.response = {
...next.response,
usage: attachToUsage(
next.response.usage,
mergeTelemetry(telemetry, telemetryFromUsage(next.response.usage)),
),
};
}
return next;
}
export function attachOmniRouteTelemetryToSseLine(
line: string,
telemetry: OmniRouteInferenceTelemetry,
): string {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) {
return line;
}
const jsonText = trimmed.slice("data:".length).trim();
if (!jsonText.startsWith("{")) {
return line;
}
try {
const parsed = JSON.parse(jsonText) as unknown;
const updated = attachOmniRouteTelemetryToPayload(parsed, telemetry);
if (updated === parsed) {
return line;
}
const prefix = line.slice(0, line.indexOf(jsonText));
const suffix = line.endsWith("\r") ? "\r" : "";
return `${prefix}${JSON.stringify(updated)}${suffix}`;
} catch {
return line;
}
}
export async function applyOmniRouteInferenceTelemetry(response: Response): Promise<Response> {
const telemetry = parseOmniRouteInferenceTelemetry(response.headers);
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream") && response.body) {
return new Response(mapSseBody(response.body, telemetry), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
if (!contentType.includes("json")) {
return response;
}
const text = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const next = attachOmniRouteTelemetryToPayload(parsed, telemetry);
if (next === parsed) {
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
return new Response(JSON.stringify(next), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
function mapSseBody(
body: ReadableStream<Uint8Array>,
telemetry: OmniRouteInferenceTelemetry,
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let pending = "";
let live = { ...telemetry };
return body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
pending += decoder.decode(chunk, { stream: true });
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
controller.enqueue(encoder.encode(`${attachOmniRouteTelemetryToSseLine(line, live)}\n`));
}
},
flush(controller) {
if (pending.length > 0) {
controller.enqueue(encoder.encode(attachOmniRouteTelemetryToSseLine(pending, live)));
}
},
}),
);
}

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { defaultOmniRouteModelsFetcher } from "../src/index.js";
test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: "authentication expired" }), {
status: 401,
statusText: "Unauthorized",
})) as typeof fetch;
try {
await assert.rejects(
() => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"),
(err: unknown) => {
assert.ok(err instanceof Error);
const rec = err as Error & { statusCode?: number; status?: number };
assert.equal(rec.statusCode, 401);
assert.equal(rec.status, 401);
assert.match(rec.message, /401/);
return true;
},
);
} finally {
globalThis.fetch = original;
}
});
test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => {
const original = globalThis.fetch;
let signal: AbortSignal | undefined;
globalThis.fetch = (async (_input, init) => {
signal = init?.signal ?? undefined;
return new Response(JSON.stringify({ object: "list", data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
try {
await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key");
assert.equal(signal instanceof AbortSignal, true);
} finally {
globalThis.fetch = original;
}
});

View File

@@ -0,0 +1,103 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyOmniRouteInferenceTelemetry,
attachOmniRouteTelemetryToPayload,
attachOmniRouteTelemetryToSseLine,
parseOmniRouteInferenceTelemetry,
} from "../src/telemetry.js";
test("parseOmniRouteInferenceTelemetry: copies cost, tokens, tok/s, winning model", () => {
const headers = new Headers({
"X-OmniRoute-Response-Cost": "0.0123",
"X-OmniRoute-Tokens-In": "10",
"X-OmniRoute-Tokens-Out": "200",
"X-OmniRoute-Tokens-Per-Second": "100.5",
"X-OmniRoute-Ttft-Ms": "300",
"X-OmniRoute-Latency-Ms": "2300",
"X-OmniRoute-Model": "winner-model",
"X-OmniRoute-Provider": "openai",
});
const got = parseOmniRouteInferenceTelemetry(headers);
assert.equal(got.costUsd, 0.0123);
assert.equal(got.tokensIn, 10);
assert.equal(got.tokensOut, 200);
assert.equal(got.tokensPerSecond, 100.5);
assert.equal(got.ttftMs, 300);
assert.equal(got.model, "winner-model");
assert.equal(got.provider, "openai");
});
test("parseOmniRouteInferenceTelemetry: omits tok/s when header missing (do not invent from latency)", () => {
const headers = new Headers({
"X-OmniRoute-Tokens-Out": "200",
"X-OmniRoute-Latency-Ms": "2000",
});
const got = parseOmniRouteInferenceTelemetry(headers);
assert.equal(got.tokensPerSecond, undefined);
assert.equal(got.tokensOut, 200);
const payload = attachOmniRouteTelemetryToPayload(
{ object: "chat.completion", usage: { prompt_tokens: 10, completion_tokens: 200 } },
got,
) as { usage: { tokens_per_second?: number } };
assert.equal(payload.usage.tokens_per_second, undefined);
});
test("attachOmniRouteTelemetryToPayload: writes usage.tokens_per_second and winning model", () => {
const got = attachOmniRouteTelemetryToPayload(
{
object: "chat.completion",
model: "combo/auto",
usage: { prompt_tokens: 10, completion_tokens: 200 },
},
{ tokensPerSecond: 80, ttftMs: 250, costUsd: 0, model: "gpt-winner" },
) as {
model: string;
usage: { tokens_per_second: number; ttft_ms: number; cost: number };
};
assert.equal(got.model, "gpt-winner");
assert.equal(got.usage.tokens_per_second, 80);
assert.equal(got.usage.ttft_ms, 250);
assert.equal(got.usage.cost, 0);
});
test("attachOmniRouteTelemetryToPayload: does not mutate /v1/models catalog JSON", () => {
const catalog = { object: "list", data: [{ id: "m1" }] };
const got = attachOmniRouteTelemetryToPayload(catalog, {
tokensPerSecond: 99,
model: "should-not-apply",
});
assert.deepEqual(got, catalog);
});
test("attachOmniRouteTelemetryToSseLine: patches terminal usage data line", () => {
const line =
'data: {"object":"chat.completion.chunk","usage":{"completion_tokens":200}}';
const got = attachOmniRouteTelemetryToSseLine(line, { tokensPerSecond: 50 });
assert.match(got, /"tokens_per_second":50/);
assert.match(got, /^data: /);
});
test("applyOmniRouteInferenceTelemetry: JSON response gets header tok/s", async () => {
const response = new Response(
JSON.stringify({
object: "chat.completion",
model: "combo/auto",
usage: { prompt_tokens: 1, completion_tokens: 20 },
}),
{
headers: {
"Content-Type": "application/json",
"X-OmniRoute-Tokens-Per-Second": "40",
"X-OmniRoute-Model": "winner",
},
},
);
const next = await applyOmniRouteInferenceTelemetry(response);
const body = JSON.parse(await next.text()) as {
model: string;
usage: { tokens_per_second: number };
};
assert.equal(body.model, "winner");
assert.equal(body.usage.tokens_per_second, 40);
});

View File

@@ -331,7 +331,18 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse.
# Pinned to exact versions per Diego's diagnosis in #12576 — floating
# `@latest` causes two CI failures:
# 1. `openclaw` ships a breaking major ~weekly; overnight builds silently
# advance to a version that no longer matches the tested combo stack.
# 2. `codex` / `claude-code` dev pre-releases (`@next`, dist-tags) mutate
# API surface without notice; reproducible builds need a SHA-pinned dev
# build, not the floating `@latest`.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
npm install -g --no-audit --no-fund \
@openai/codex@0.153.2 \
@anthropic-ai/claude-code@2.1.260 \
droid@0.212.0 \
openclaw@2026.9.1
USER node

View File

@@ -7,19 +7,19 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
<div align="center">
## 💰 ~1.51B Free Tokens / Month
## 💰 ~1.47B Free Tokens / Month
</div>
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **437 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 437 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -209,7 +209,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<br/>
<br/>
@@ -518,9 +518,9 @@ Pix copia-e-cola:
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.51B tokens/month** from the documented,
The main free-tier headline remains **~1.47B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.13B**. Radar is an optional, signed catalog overlay for people who want fresher
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
@@ -648,7 +648,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
</div>
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **437 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
<div align="center">
@@ -1307,7 +1307,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 38 documented recurring pools / 437 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table>

View File

@@ -224,6 +224,14 @@ features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
frequently pattern-match those chunks against malware signatures.
The scanner configuration we use lives at [`socket.yml`](socket.yml) in the
repo root (Socket.dev GitHub App format v2 — see
<https://docs.socket.dev/docs/socket-yml>). It explicitly excludes
non-shipped directories (`tests/`, `_tasks/`, `_references/`, `_ideia/`,
`_mono_repo/`, `docs/`, etc.) so the scanner only reports on code paths that
actually reach published users — the scan itself is driven by the Socket
GitHub App reading that file, not by a workflow in this repository.
For each finding category we maintain a per-finding maintainer attestation:
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —

View File

@@ -16,6 +16,29 @@ function ensureBackup(configPath) {
return backupPath;
}
function mergeClaudeSettings(existingContent, generatedContent) {
const generated = JSON.parse(generatedContent);
let current = {};
if (existingContent && existingContent.trim()) {
current = JSON.parse(existingContent);
if (!current || typeof current !== "object" || Array.isArray(current)) current = {};
}
return JSON.stringify(
{
...current,
...generated,
env: {
...(current.env && typeof current.env === "object" && !Array.isArray(current.env)
? current.env
: {}),
...(generated.env || {}),
},
},
null,
2
);
}
async function runConfigListCommand(opts = {}) {
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.ts");
const tools = await detectAllTools();
@@ -120,7 +143,12 @@ async function runConfigSetCommand(toolId, opts = {}) {
const backupPath = ensureBackup(result.configPath);
if (backupPath) printInfo(`Backup saved to: ${backupPath}`);
fs.writeFileSync(result.configPath, result.content, "utf-8");
let content = result.content;
if (toolId === "claude" && fs.existsSync(result.configPath)) {
content = mergeClaudeSettings(fs.readFileSync(result.configPath, "utf-8"), result.content);
}
fs.writeFileSync(result.configPath, content, "utf-8");
printSuccess(`Config written to ${result.configPath}`);
return 0;
}

View File

@@ -18,7 +18,7 @@ export function registerTunnel(program) {
});
tunnel
.command("create [type]")
.command("create")
.description(t("tunnel.createDescription"))
.addArgument(
new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare")

View File

@@ -0,0 +1 @@
- **feat(providers):** skip GitHub combo members missing from the live synced catalog, and drop Copilot models that are policy-disabled or hidden from the model picker ([#12473](https://github.com/diegosouzapw/OmniRoute/pull/12473)) — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **feat(api):** Emit gateway-measured `tokens_per_second` (TTFT excluded) on streaming usage and `X-OmniRoute-Tokens-Per-Second` when first-token latency is known ([#12616](https://github.com/diegosouzapw/OmniRoute/issues/12616))

View File

@@ -0,0 +1 @@
- **feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode inference payloads** — the official plugin copies `X-OmniRoute-Response-Cost`, token counts, `X-OmniRoute-Tokens-Per-Second` / `usage.tokens_per_second`, TTFT, and the winning `X-OmniRoute-Model` onto the JSON/SSE body OpenCode already consumes. Missing tok/s is left unset (never `tokens / latency`). (#12636)

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295))

View File

@@ -0,0 +1 @@
- **fix(providers):** reclassify Cerebras as a one-time $5 signup credit (payment method required, 30-day validity), not a recurring no-card 1M tokens/day trial ([#11773](https://github.com/diegosouzapw/OmniRoute/issues/11773))

View File

@@ -0,0 +1 @@
- **fix(combos):** deleting a combo now clears its persisted LKGP pins instead of leaving unreachable `key_value` rows behind ([#12326](https://github.com/diegosouzapw/OmniRoute/issues/12326))

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295), [#12368](https://github.com/diegosouzapw/OmniRoute/pull/12368))

View File

@@ -0,0 +1 @@
- **fix(i18n):** wrap `ccOnboardingKeyPlaceholder` in ICU single quotes across all 43 locales so angle brackets render literally instead of being parsed as rich-text tags, which crashed the Claude Code onboarding block with `INVALID_MESSAGE: INVALID_TAG` ([#12302](https://github.com/diegosouzapw/OmniRoute/issues/12302))

View File

@@ -0,0 +1 @@
- **fix(models):** publish `effort_tiers` on Kimi K3's synced base-model entries (`kmca/k3`, `kmca/k3-256k`) so catalog-only clients (OpenCode, plain SDK pickers) can see and select the reasoning tiers (`low`/`high`/`max`) the synced metadata already carried — the `isSkippedEffortProvider` gate no longer suppresses tier visibility on those base entries, while synthetic `<id>-<tier>` variant generation stays prevented and Codex/GLM base models remain excluded unchanged ([#12299](https://github.com/diegosouzapw/OmniRoute/issues/12299))

View File

@@ -0,0 +1 @@
- **fix(providers):** add `CLAUDE_CODE_CLIENT_VERSION` and `GITHUB_COPILOT_CLI_VERSION` env overrides so Anthropic/Copilot client-version gates can be unblocked without a rebuild ([#12417](https://github.com/diegosouzapw/OmniRoute/issues/12417))

View File

@@ -0,0 +1 @@
- **fix(db):** back-fill `last_ping_at` and `last_pinged_reset_key` on `provider_connections` during schema reconciliation so divergent lineages that skipped `123_quota_auto_ping` still accept quota auto-ping writes ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470) — thanks @KooshaPari)

View File

@@ -0,0 +1 @@
- OpenCode plugin `/v1/models` catalog fetch now waits 30s by default and attaches HTTP `statusCode` on 401/5xx so host fallback plugins can hop instead of seeing an untyped AbortError/UnknownError.

View File

@@ -0,0 +1 @@
- **CI:** the OpenAPI security-tier gate now mirrors `isAlwaysProtectedPath()` in full — it also reads `ALWAYS_PROTECTED_API_PATTERNS`, so the pattern-gated credential routes (`/api/providers/{id}/{claude,codex}-auth/{export,apply-local}`, GHSA-5926-2w35-7h4q) no longer report as unannotated. (#12605)

View File

@@ -0,0 +1 @@
- **fix(api):** GET /v1/models no longer waits forever on a hung coalesced catalog rebuild; cold-path waits are bounded (`CATALOG_BUILD_TIMEOUT_MS`, default 8s) and a last-good 200 is served when the rebuild times out ([#12627](https://github.com/diegosouzapw/OmniRoute/issues/12627)).

View File

@@ -0,0 +1 @@
Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider.

View File

@@ -0,0 +1 @@
- **fix(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised).

View File

@@ -0,0 +1 @@
- **chore(quality):** rebaseline `src/lib/db/apiKeys.ts` for the ACL the key-creation path now preserves ([#12352](https://github.com/diegosouzapw/OmniRoute/pull/12352))

View File

@@ -0,0 +1 @@
- **chore(quality):** rebaseline `src/sse/handlers/chat.ts` for the effective-input persistence the continuation fix needs ([#12641](https://github.com/diegosouzapw/OmniRoute/pull/12641))

View File

@@ -0,0 +1 @@
- **chore(quality):** rebaseline `open-sse/services/combo.ts` for the reset-aware scoring the HouMinXi batch stacked ([#12637](https://github.com/diegosouzapw/OmniRoute/pull/12637))

View File

@@ -1,4 +1,7 @@
{
"_rebaseline_2026_09_03_reset_aware_model_family": "Own growth: open-sse/services/combo.ts 4036->4041 (+5). buildAutoCandidates now keys the reset-aware quota cache by getQuotaFetchScope and spreads requestedModel onto the connection so Gemini windows stay off a Claude-empty Antigravity account. Irreducible wiring at the existing fetchResetAwareQuotaWithCache call site; the family helper itself lives in antigravityQuotaFamily.ts. Covered by tests/unit/reset-aware-request-scope-12600.test.ts.",
"_rebaseline_2026_09_03_overloaded_not_provider_breaker": "fix/overloaded-not-provider-breaker own growth: open-sse/services/combo.ts 4036->4075 (check-file-size split-newline, +39). Circuit-open pre-skip now records the breaker retryAfter and, when every target was skipped that way, waits the short reset via resolveCircuitOpenWaitDecision (new leaf in comboCooldownRetry.ts) instead of crystallizing ALL_TARGETS_SKIPPED in ~43ms. skippedForCircuitOpen / earliestCircuitOpenRetryMs reset each setTry so a later iteration cannot inherit a stale retryAfter. Irreducible at the existing ALL_TARGETS_SKIPPED chokepoint (same pattern as #7301/#8213 cooldown-wait). Predicate itself lives in circuitBreaker.ts / comboPredicates.ts / chatPredicates.ts, all under cap. Covered by tests/unit/overloaded-not-provider-breaker.test.ts + combo-cooldown-retry.test.ts.",
"_rebaseline_2026_09_03_12649_free_tier_reaudit_gateways": "PR #12649 (fix/free-tier-quota-reaudit) own growth: src/shared/constants/providers/apikey/gateways.ts 1459->1462 (+3 = the nara authHint rewritten for the re-audited 7M/day plan now wraps to two lines, plus the Prettier reflow of two pre-existing >100-col authHint lines (oneminai, freebuff) that lint-staged enforces on any touch of the file; additive text at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #11786 seekai, #10987 logfare, #10531 freebuff). Covered by tests/unit/free-tier-reaudit-2026-09.test.ts and tests/unit/free-providers-batch-2026-07.test.ts.",
"_rebaseline_2026_09_03_moonshot_native_quota": "PR feat/moonshot-native-quota own growth on release/v3.8.51: src/lib/db/migrationRunner.ts 1201->1206 (+5, case 172 retroactive guard for daily_quota_reset_* columns); src/sse/handlers/chat.ts 2434->2450 (+16, registerMoonshotQuotaFetcher + startup node scan at the existing quota-fetcher registration chokepoint); src/sse/services/auth.ts 3427->3450 (+23, resolveDailyResetForProvider + dailyReset arg on checkFallbackError); open-sse/services/accountFallback.ts 2422->2461 (+39, compatible-node credits_exhausted carve-out + TPD node-clock lock); tests/unit/account-fallback-service.test.ts 2008->2056 (+48, TPD/empty-wallet cases). Wiring at existing chokepoints; Moonshot host predicates, daily reset clock, and the balance fetcher live in new leaves under cap. Covered by tests/unit/moonshot-*.test.ts + account-fallback-service.test.ts (135/135 focused).",
"_rebaseline_2026_09_02_11786_seekai_provider": "PR #11786 (feat/11786-seekai-provider, closes #11786) own growth: src/shared/constants/providers/apikey/gateways.ts 1438->1458 (check-file-size split-newline=1459; the seekai APIKEY_PROVIDERS_GATEWAYS catalog entry plus authHint, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #10987 logfare, #10531 freebuff). Covered by tests/unit/seekai-provider.test.ts.",
"_rebaseline_2026_09_02_12325_generic_429_invalidate": "PR #12325 own growth: open-sse/handlers/chatCore.ts 5946->5955 (+9 = the non-Codex 429 else-if that drops the generic quota wrapper and stamps force-refresh, plus a source-regex breadcrumb). Irreducible call-site wiring next to the existing Codex 429 invalidateCodexQuotaCache branch; not extractable without splitting handleChatCore mid-response. Covered by tests/unit/generic-quota-fetcher.test.ts (31/31) and tests/unit/antigravity-429-quota-cooldown.test.ts.",
@@ -422,7 +425,7 @@
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2467,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
"open-sse/services/combo.ts": 4036,
"open-sse/services/combo.ts": 4080,
"open-sse/translator/response/openai-responses.ts": 1466,
"open-sse/utils/cursorAgentProtobuf.ts": 1547,
"open-sse/utils/proxyFetch.ts": 1271,
@@ -431,7 +434,7 @@
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5018,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5066,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631,
@@ -446,15 +449,15 @@
"src/app/api/providers/[id]/test/route.ts": 1252,
"src/app/api/v1/models/catalog.ts": 2075,
"src/app/docs/lib/openapi.generated.ts": 1347,
"src/lib/db/apiKeys.ts": 1610,
"src/lib/db/apiKeys.ts": 1625,
"src/lib/db/core.ts": 1745,
"src/lib/db/migrationRunner.ts": 1206,
"src/lib/tailscaleTunnel.ts": 1208,
"src/lib/tokenHealthCheck.ts": 1218,
"src/shared/components/RequestLoggerV2.tsx": 1718,
"src/shared/constants/providers/apikey/gateways.ts": 1459,
"src/shared/constants/providers/apikey/gateways.ts": 1462,
"src/shared/services/cliRuntime.ts": 1296,
"src/sse/handlers/chat.ts": 2450,
"src/sse/handlers/chat.ts": 2454,
"src/sse/services/auth.ts": 3450,
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656
@@ -555,7 +558,7 @@
"open-sse/services/accountFallback.ts": "1978",
"open-sse/services/adobeFireflyClient.ts": "2385",
"open-sse/services/claudeCodeCompatible.ts": "1202",
"open-sse/services/combo.ts": "3648",
"open-sse/services/combo.ts": "4075",
"open-sse/services/compression/strategySelector.ts": "1060",
"open-sse/services/rateLimitManager.ts": "1167",
"open-sse/translator/response/openai-responses.ts": "1204",
@@ -637,5 +640,9 @@
"_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).",
"_rebaseline_2026_09_03_12604_claude_code_2_1_258": "PR #12604 (bump da wire identity do Claude Code 2.1.220->2.1.258, commits do @ggiak vindos do #12402) crescimento proprio: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1606->1607 (+1, a linha do seletor que acompanha a nova versao de identidade). Uma linha num painel de settings ja existente; nao ha o que extrair. Coberto por client-identity-profiles e claude-codex-identity-version-sync (138/138 focados).",
"_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base).",
"_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente."
"_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente.",
"_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).",
"_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).",
"_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).",
"_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped."
}

View File

@@ -170,6 +170,13 @@ services:
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
volumes:
- ./data:/app/data
# SECURITY: mounting the host Docker socket gives this container full
# control over the host Docker daemon — it can create/list/stop/rm any
# container the host runs. It is here so the in-container auto-updater
# (src/lib/system/autoUpdate.ts) can recreate the stack. Only use this
# profile on a single-tenant workstation you trust, and never publish
# its ports beyond 127.0.0.1. See docs/guides/DOCKER_GUIDE.md →
# "Escape hatch: configure the container's own CLIs" for the threat model.
- /var/run/docker.sock:/var/run/docker.sock
- /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
- ${AUTO_UPDATE_HOST_REPO_DIR:-.}:/workspace/omniroute:rw

View File

@@ -120,3 +120,22 @@ against the **parent's** tenant lane.
The byte-level lanes bound the memory-heavy parse/compress path; the adaptive lanes
bound dispatch cost per tenant. #9654's criterion 1 ("one session's burst does not 503
another") is enforced by system 1 unconditionally and by system 2 once opt-in is enabled.
## 4. One-process long `/v1/responses` (healthy-headroom)
[#10437](https://github.com/diegosouzapw/OmniRoute/pull/10437) added
`tryAcquireHealthyHeadroom` so a second structurally-heavy request is admitted
when the heap is below `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO`. The BYTE
path used by `admitChatRequest` (bodies ≥ `OMNIROUTE_CHAT_LARGE_BODY_BYTES`,
default 256KiB, including `POST /v1/responses`) uses the **same** escape.
This is the supported **one-process** recipe for more than two concurrent long
SSE `/v1/responses`: raise primary + healthy-headroom only as far as the heap
and the process-wide inflight-byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES`
/ #10110) allow. Tens of long SSE clients (4050) is that memory-budget
question, not a hard “max 2” product limit. A pressured heap still sheds with
retryable `503` so #7849 does not return.
To **multiply heaps**, run N independent `DATA_DIR`s (#11024). Never
`replicas > 1` on one SQLite file (#10350). This section is not a reopen of
the DATA_DIR scale-out recipe.

View File

@@ -10,16 +10,16 @@ Mermaid sources (`.mmd`) and exported SVGs for OmniRoute v3.8.0 architecture flo
## Canonical diagrams
| Source | Exported | Used in |
| ---------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ |
| [request-pipeline.mmd](./request-pipeline.mmd) | [SVG](./exported/request-pipeline.svg) | docs/architecture/ARCHITECTURE.md, docs/architecture/CODEBASE_DOCUMENTATION.md |
| Source | Exported | Used in |
| -------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ |
| [request-pipeline.mmd](./request-pipeline.mmd) | [SVG](./exported/request-pipeline.svg) | docs/architecture/ARCHITECTURE.md, docs/architecture/CODEBASE_DOCUMENTATION.md |
| [auto-combo-scoring.mmd](./auto-combo-scoring.mmd) | [SVG](./exported/auto-combo-scoring.svg) | docs/routing/AUTO-COMBO.md |
| [resilience-3layers.mmd](./resilience-3layers.mmd) | [SVG](./exported/resilience-3layers.svg) | docs/architecture/RESILIENCE_GUIDE.md, CLAUDE.md |
| [i18n-flow.mmd](./i18n-flow.mmd) | [SVG](./exported/i18n-flow.svg) | docs/guides/I18N.md |
| [mcp-tools.mmd](./mcp-tools.mmd) | [SVG](./exported/mcp-tools.svg) | docs/frameworks/MCP-SERVER.md |
| [cloud-agent-flow.mmd](./cloud-agent-flow.mmd) | [SVG](./exported/cloud-agent-flow.svg) | docs/frameworks/CLOUD_AGENT.md |
| [authz-pipeline.mmd](./authz-pipeline.mmd) | [SVG](./exported/authz-pipeline.svg) | docs/architecture/AUTHZ_GUIDE.md |
| [db-schema-overview.mmd](./db-schema-overview.mmd) | [SVG](./exported/db-schema-overview.svg) | docs/architecture/CODEBASE_DOCUMENTATION.md |
| [resilience-3layers.mmd](./resilience-3layers.mmd) | [SVG](./exported/resilience-3layers.svg) | docs/architecture/RESILIENCE_GUIDE.md, CLAUDE.md |
| [i18n-flow.mmd](./i18n-flow.mmd) | [SVG](./exported/i18n-flow.svg) | docs/guides/I18N.md |
| [mcp-tools.mmd](./mcp-tools.mmd) | [SVG](./exported/mcp-tools.svg) | docs/frameworks/MCP-SERVER.md |
| [cloud-agent-flow.mmd](./cloud-agent-flow.mmd) | [SVG](./exported/cloud-agent-flow.svg) | docs/frameworks/CLOUD_AGENT.md |
| [authz-pipeline.mmd](./authz-pipeline.mmd) | [SVG](./exported/authz-pipeline.svg) | docs/architecture/AUTHZ_GUIDE.md |
| [db-schema-overview.mmd](./db-schema-overview.mmd) | [SVG](./exported/db-schema-overview.svg) | docs/architecture/CODEBASE_DOCUMENTATION.md |
## Hand-authored animated diagrams
@@ -34,7 +34,7 @@ inside GitHub's `<img>` sandbox:
| [combo-always-on.svg](./combo-always-on.svg) | style reference | Animated priority-combo fallback (4 layers, 16s loop). Edit the SVG directly — there is no `.mmd` source. |
| [cli-terminal.svg](./cli-terminal.svg) | README.md (root) | Compact half-height animated terminal (1200×350): 3 real CLI commands cycling with typewriter + scrolling subcommand ticker; first frame = completed providers screen. Edit the SVG directly — there is no `.mmd` source. |
| [compression-pipeline.svg](./compression-pipeline.svg) | README.md (root) | Animated 12-engine compression funnel (8s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.51B/mo quantified headline, 20-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.47B/mo quantified headline, 16-pool + Groq-caps budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [readme-hero.svg](./readme-hero.svg) | README.md (root) | Animated hero card (tagline, live provider/free-access headline, full-width compression bar demo, 6 stat chips). Edit the SVG directly — there is no `.mmd` source. |
| [promise-pillars.svg](./promise-pillars.svg) | README.md (root) | Animated "The Promise" 6-pillar card (12s border-highlight sweep). Edit the SVG directly — there is no `.mmd` source. |
| [why-pain-fix.svg](./why-pain-fix.svg) | README.md (root) | Animated "Why OmniRoute" 10-row pain-vs-fix ledger (15s green row sweep). Edit the SVG directly — there is no `.mmd` source. |

View File

@@ -1,5 +1,5 @@
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in the first month with signup credits. The catalog contains 437 rows, 430 active and 7 discontinued, grouped into 38 recurring pool keys; 20 pools have a published positive monthly token budget and 18 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 13 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, Gemini 60 million, Cerebras 30 million, Cloudflare AI 30 million, API Airforce 24 million, Ollama Cloud 20 million, Groq 15 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. Live usage remains available at /dashboard/free-tiers.">
<desc>Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately.</desc>
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.47 billion free tokens per month steady, up to about 2.10 billion in the first month with signup credits. The catalog contains 444 rows, 437 active and 7 discontinued, grouped into 34 recurring pool keys; 16 pools have a published positive monthly token budget and 18 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 13 providers carry a terms-of-service avoid flag. The 16 quantified pools plus five per-model Groq caps are Mistral 1 billion, Nara 210 million, LLM7 150 million, Groq 30 million across five per-model caps, Cloudflare AI 30 million, API Airforce 24 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. A further 6 million behind regional identity verification (ModelScope) is also shown apart. Live usage remains available at /dashboard/free-tiers.">
<desc>Pool-deduplicated chart of the 16 recurring free-token pools with positive published budgets (plus Groq's five per-model caps as one segment), plus signup credits and uncapped providers shown separately.</desc>
<defs>
<pattern id="gridPaperF" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="1"/>
@@ -61,10 +61,10 @@
<animate attributeName="opacity" values="0;1;1;0" dur="2.4s" begin="1.6s" repeatCount="indefinite"/>
</circle>
</g>
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.51B</text>
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.47B</text>
<text x="62" y="266" font-family="Consolas, 'Courier New', monospace" font-size="15" letter-spacing="3" font-weight="700" fill="#a1a1aa">FREE TOKENS / MONTH &#183; <tspan fill="#22c55e">STEADY</tspan></text>
<text x="62" y="298" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16" fill="#F7F6FC">up to <tspan font-weight="800" fill="#22c55e">~2.13B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">38 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">437 catalog entries</tspan> &#183; one endpoint</text>
<text x="62" y="298" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16" fill="#F7F6FC">up to <tspan font-weight="800" fill="#22c55e">~2.10B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">34 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">444 catalog entries</tspan> &#183; one endpoint</text>
<!-- ═══ Panel · The honest math ═══ -->
<rect x="680" y="84" width="460" height="216" rx="14" fill="#161b22" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
@@ -75,66 +75,60 @@
</line>
<text x="836" y="156" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">every rate limit &#183; 24/7</text>
<text x="836" y="176" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#ef4444" opacity="0.85">we don't publish that</text>
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.51B</text>
<text x="704" y="240" font-family="Consolas, 'Courier New', monospace" font-size="34" font-weight="800" fill="#22c55e">~1.47B</text>
<text x="836" y="224" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#a1a1aa">each shared free pool</text>
<text x="836" y="244" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#22c55e">counted once &#10003;</text>
<text x="704" y="280" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#f59e0b"><tspan font-weight="800">13 providers</tspan> ToS-flagged <tspan fill="#71717a">&#8212; we flag it &#183; you decide</tspan></text>
<!-- ═══ Budget bar · 20 quantified recurring pools ═══ -->
<text x="60" y="356" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">WHERE IT COMES FROM &#183; <tspan fill="#F7F6FC">20 QUANTIFIED RECURRING POOLS</tspan></text>
<!-- ═══ Budget bar · 17 quantified recurring pools + Groq's five per-model caps (one segment) ═══ -->
<text x="60" y="356" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">WHERE IT COMES FROM &#183; <tspan fill="#F7F6FC">16 QUANTIFIED POOLS + 5 GROQ PER-MODEL CAPS</tspan></text>
<g clip-path="url(#barShapeF)">
<rect x="60" y="372" width="1080" height="18" fill="#1c2230"/>
<g clip-path="url(#barRevF)">
<rect x="60.0" y="372" width="661.4" height="18" fill="#6c5ce7"/>
<rect x="722.4" y="372" width="99.2" height="18" fill="#00b894"/>
<rect x="822.6" y="372" width="99.2" height="18" fill="#0984e3"/>
<rect x="922.9" y="372" width="39.7" height="18" fill="#e17055"/>
<rect x="963.5" y="372" width="19.8" height="18" fill="#fdcb6e"/>
<rect x="984.4" y="372" width="19.8" height="18" fill="#e84393"/>
<rect x="1005.2" y="372" width="15.9" height="18" fill="#00cec9"/>
<rect x="1022.1" y="372" width="13.2" height="18" fill="#d63031"/>
<rect x="1036.3" y="372" width="9.9" height="18" fill="#a29bfe"/>
<rect x="1047.3" y="372" width="7.5" height="18" fill="#55efc4"/>
<rect x="1055.8" y="372" width="7.5" height="18" fill="#74b9ff"/>
<rect x="1064.3" y="372" width="7.5" height="18" fill="#ffeaa7"/>
<rect x="1072.8" y="372" width="7.5" height="18" fill="#fab1a0"/>
<rect x="1081.3" y="372" width="7.5" height="18" fill="#81ecec"/>
<rect x="1089.9" y="372" width="7.5" height="18" fill="#6c5ce7"/>
<rect x="1098.4" y="372" width="7.5" height="18" fill="#00b894"/>
<rect x="1106.9" y="372" width="7.5" height="18" fill="#0984e3"/>
<rect x="1115.4" y="372" width="7.5" height="18" fill="#e17055"/>
<rect x="1124.0" y="372" width="7.5" height="18" fill="#fdcb6e"/>
<rect x="1132.5" y="372" width="7.5" height="18" fill="#e84393"/>
<rect x="60.0" y="372" width="686.0" height="18" fill="#6c5ce7"/>
<rect x="747.0" y="372" width="139.7" height="18" fill="#00b894"/>
<rect x="887.7" y="372" width="99.8" height="18" fill="#0984e3"/>
<rect x="988.5" y="372" width="20.0" height="18" fill="#e17055"/>
<rect x="1009.5" y="372" width="20.0" height="18" fill="#e84393"/>
<rect x="1030.5" y="372" width="16.0" height="18" fill="#00cec9"/>
<rect x="1047.5" y="372" width="7.5" height="18" fill="#d63031"/>
<rect x="1056.0" y="372" width="7.5" height="18" fill="#a29bfe"/>
<rect x="1064.5" y="372" width="7.5" height="18" fill="#55efc4"/>
<rect x="1073.0" y="372" width="7.5" height="18" fill="#74b9ff"/>
<rect x="1081.5" y="372" width="7.5" height="18" fill="#ffeaa7"/>
<rect x="1090.0" y="372" width="7.5" height="18" fill="#fab1a0"/>
<rect x="1098.5" y="372" width="7.5" height="18" fill="#81ecec"/>
<rect x="1107.0" y="372" width="7.5" height="18" fill="#6c5ce7"/>
<rect x="1115.5" y="372" width="7.5" height="18" fill="#00b894"/>
<rect x="1124.0" y="372" width="7.5" height="18" fill="#0984e3"/>
<rect x="1132.5" y="372" width="7.5" height="18" fill="#e17055"/>
</g>
</g>
<circle r="3.2" fill="#F7F6FC">
<animateMotion path="M 60,381 L 1140,381" keyPoints="0;0;1;1" keyTimes="0;0.02;0.24;1" calcMode="linear" dur="10s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;0.02;0.23;0.26;1" dur="10s" repeatCount="indefinite"/>
</circle>
<text x="60" y="416" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#71717a">each segment = one recurring pool &#183; widths floored so every pool shows &#183; audited pool budgets below</text>
<text x="60" y="416" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#71717a">each segment = one recurring pool (Groq = its five per-model caps) &#183; widths floored so every pool shows &#183; audited pool budgets below</text>
<!-- ═══ Per-pool grid (20 quantified recurring pools) ═══ -->
<!-- ═══ Per-pool grid (16 quantified recurring pools + Groq's five per-model caps) ═══ -->
<g font-family="Consolas, 'Courier New', monospace" font-size="12.5">
<circle cx="66" cy="452" r="5" fill="#6c5ce7"/><text x="78" y="456" fill="#c9d1d9">Mistral <tspan fill="#71717a">1.00B</tspan></text>
<circle cx="346" cy="452" r="5" fill="#00b894"/><text x="358" y="456" fill="#c9d1d9">LLM7 <tspan fill="#71717a">150M</tspan></text>
<circle cx="626" cy="452" r="5" fill="#0984e3"/><text x="638" y="456" fill="#c9d1d9">Nara <tspan fill="#71717a">150M</tspan></text>
<circle cx="906" cy="452" r="5" fill="#e17055"/><text x="918" y="456" fill="#c9d1d9">Gemini <tspan fill="#71717a">60M</tspan></text>
<circle cx="66" cy="482" r="5" fill="#fdcb6e"/><text x="78" y="486" fill="#c9d1d9">Cerebras <tspan fill="#71717a">30M</tspan></text>
<circle cx="346" cy="482" r="5" fill="#e84393"/><text x="358" y="486" fill="#c9d1d9">Cloudflare AI <tspan fill="#71717a">30M</tspan></text>
<circle cx="626" cy="482" r="5" fill="#00cec9"/><text x="638" y="486" fill="#c9d1d9">API Airforce <tspan fill="#71717a">24M</tspan></text>
<circle cx="906" cy="482" r="5" fill="#d63031"/><text x="918" y="486" fill="#c9d1d9">Ollama Cloud <tspan fill="#71717a">20M</tspan></text>
<circle cx="66" cy="512" r="5" fill="#a29bfe"/><text x="78" y="516" fill="#c9d1d9">Groq <tspan fill="#71717a">15M</tspan></text>
<circle cx="346" cy="512" r="5" fill="#55efc4"/><text x="358" y="516" fill="#c9d1d9">Bluesminds <tspan fill="#71717a">7.2M</tspan></text>
<circle cx="626" cy="512" r="5" fill="#74b9ff"/><text x="638" y="516" fill="#c9d1d9">SambaNova <tspan fill="#71717a">6M</tspan></text>
<circle cx="906" cy="512" r="5" fill="#ffeaa7"/><text x="918" y="516" fill="#c9d1d9">Arcee <tspan fill="#71717a">4.8M</tspan></text>
<circle cx="66" cy="542" r="5" fill="#fab1a0"/><text x="78" y="546" fill="#c9d1d9">Navy <tspan fill="#71717a">4.5M</tspan></text>
<circle cx="346" cy="542" r="5" fill="#81ecec"/><text x="358" y="546" fill="#c9d1d9">BazaarLink <tspan fill="#71717a">3.6M</tspan></text>
<circle cx="626" cy="542" r="5" fill="#6c5ce7"/><text x="638" y="546" fill="#c9d1d9">OpenRouter <tspan fill="#71717a">1.2M</tspan></text>
<circle cx="906" cy="542" r="5" fill="#00b894"/><text x="918" y="546" fill="#c9d1d9">Cohere <tspan fill="#71717a">800K</tspan></text>
<circle cx="66" cy="572" r="5" fill="#0984e3"/><text x="78" y="576" fill="#c9d1d9">HuggingChat <tspan fill="#71717a">500K</tspan></text>
<circle cx="346" cy="572" r="5" fill="#e17055"/><text x="358" y="576" fill="#c9d1d9">Morph <tspan fill="#71717a">400K</tspan></text>
<circle cx="626" cy="572" r="5" fill="#fdcb6e"/><text x="638" y="576" fill="#c9d1d9">Hugging Face <tspan fill="#71717a">200K</tspan></text>
<circle cx="906" cy="572" r="5" fill="#e84393"/><text x="918" y="576" fill="#c9d1d9">Kiro <tspan fill="#71717a">25K</tspan></text>
<circle cx="346" cy="452" r="5" fill="#00b894"/><text x="358" y="456" fill="#c9d1d9">Nara <tspan fill="#71717a">210M</tspan></text>
<circle cx="626" cy="452" r="5" fill="#0984e3"/><text x="638" y="456" fill="#c9d1d9">LLM7 <tspan fill="#71717a">150M</tspan></text>
<circle cx="906" cy="452" r="5" fill="#e17055"/><text x="918" y="456" fill="#c9d1d9">Groq <tspan fill="#71717a">30M &#183; 5 caps</tspan></text>
<circle cx="66" cy="482" r="5" fill="#e84393"/><text x="78" y="486" fill="#c9d1d9">Cloudflare AI <tspan fill="#71717a">30M</tspan></text>
<circle cx="346" cy="482" r="5" fill="#00cec9"/><text x="358" y="486" fill="#c9d1d9">API Airforce <tspan fill="#71717a">24M</tspan></text>
<circle cx="626" cy="482" r="5" fill="#d63031"/><text x="638" y="486" fill="#c9d1d9">Bluesminds <tspan fill="#71717a">7.2M</tspan></text>
<circle cx="906" cy="482" r="5" fill="#a29bfe"/><text x="918" y="486" fill="#c9d1d9">SambaNova <tspan fill="#71717a">6M</tspan></text>
<circle cx="66" cy="512" r="5" fill="#55efc4"/><text x="78" y="516" fill="#c9d1d9">Arcee <tspan fill="#71717a">4.8M</tspan></text>
<circle cx="346" cy="512" r="5" fill="#74b9ff"/><text x="358" y="516" fill="#c9d1d9">Navy <tspan fill="#71717a">4.5M</tspan></text>
<circle cx="626" cy="512" r="5" fill="#ffeaa7"/><text x="638" y="516" fill="#c9d1d9">BazaarLink <tspan fill="#71717a">3.6M</tspan></text>
<circle cx="906" cy="512" r="5" fill="#fab1a0"/><text x="918" y="516" fill="#c9d1d9">OpenRouter <tspan fill="#71717a">1.2M</tspan></text>
<circle cx="66" cy="542" r="5" fill="#81ecec"/><text x="78" y="546" fill="#c9d1d9">Cohere <tspan fill="#71717a">800K</tspan></text>
<circle cx="346" cy="542" r="5" fill="#6c5ce7"/><text x="358" y="546" fill="#c9d1d9">HuggingChat <tspan fill="#71717a">500K</tspan></text>
<circle cx="626" cy="542" r="5" fill="#00b894"/><text x="638" y="546" fill="#c9d1d9">Morph <tspan fill="#71717a">400K</tspan></text>
<circle cx="906" cy="542" r="5" fill="#0984e3"/><text x="918" y="546" fill="#c9d1d9">Hugging Face <tspan fill="#71717a">200K</tspan></text>
<circle cx="66" cy="572" r="5" fill="#e17055"/><text x="78" y="576" fill="#c9d1d9">Kiro <tspan fill="#71717a">25K</tspan></text>
</g>
<!-- ═══ First-month signup credits ═══ -->
@@ -178,10 +172,14 @@
<text x="397" y="717" text-anchor="middle">OpenCode Zen</text>
<rect x="458" y="702" width="56" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="486" y="717" text-anchor="middle">baidu</text>
<rect x="522" y="702" width="30" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="537" y="717" text-anchor="middle">&#8230;</text>
<rect x="522" y="702" width="62" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="553" y="717" text-anchor="middle">Gemini</text>
<rect x="592" y="702" width="100" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="642" y="717" text-anchor="middle">Ollama Cloud</text>
<rect x="700" y="702" width="30" height="22" rx="11" fill="#1c2230" stroke="#ffffff" stroke-opacity="0.08"/>
<text x="715" y="717" text-anchor="middle">&#8230;</text>
</g>
<text x="608" y="718" font-family="Consolas, 'Courier New', monospace" font-size="13.5" fill="#F7F6FC"><tspan fill="#f59e0b" font-weight="700">$10</tspan> OpenRouter top-up &#8594; <tspan fill="#22c55e" font-weight="700">+24M/mo</tspan></text>
<text x="760" y="718" font-family="Consolas, 'Courier New', monospace" font-size="13.5" fill="#F7F6FC"><tspan fill="#f59e0b" font-weight="700">$10</tspan> OpenRouter top-up &#8594; <tspan fill="#22c55e" font-weight="700">+24M/mo</tspan></text>
<text x="60" y="746" font-family="Consolas, 'Courier New', monospace" font-size="11" fill="#71717a">surfaced separately &#8212; never inflates the headline</text>
<!-- ═══ Footer strip ═══ -->

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 356 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 150+ providers with a free tier and 53 recurring or keyless free-forever providers. Every tool works: 36 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 356 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 150+ providers with a free tier and 52 recurring or keyless free-forever providers. Every tool works: 36 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -73,7 +73,7 @@
<circle cx="6.6" cy="6.6" r="1.4" fill="#fdcb6e" stroke="none"/>
</g>
<text x="862" y="170" font-size="18" font-weight="800" fill="#fdcb6e">$0 to start</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">150+ providers with a free tier, 53 free</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">150+ providers with a free tier, 52 free</text>
<text x="826" y="226" font-size="13.5" fill="#a1a1aa">forever — Qoder, Pollinations, Cloudflare,</text>
<text x="826" y="248" font-size="13.5" fill="#a1a1aa">SiliconFlow… No card needed.</text>
</g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 356 AI providers, 150+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 356 AI providers, 150+ free tiers, about 1.47B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
<defs>
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -72,7 +72,7 @@
<text x="320" y="471" font-size="17" font-weight="800" fill="#7ee787">90+</text>
<text x="320" y="490" font-size="11" fill="#a1a1aa">FREE TIERS</text>
<rect x="420" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#22c55e" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="506" y="471" font-size="17" font-weight="800" fill="#7ee787">~1.51B</text>
<text x="506" y="471" font-size="17" font-weight="800" fill="#7ee787">~1.47B</text>
<text x="506" y="490" font-size="11" fill="#a1a1aa">FREE TOKENS / MO</text>
<rect x="606" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#e17055" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="692" y="471" font-size="17" font-weight="800" fill="#e17055">1595%</text>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -236,20 +236,18 @@ xp_for_level(n) = floor(100 * n^1.5)
### XP Rewards
| Action | XP | Description |
| ------------------ | --- | --------------------------------------------------------- |
| `request` | 1 | Per successful LLM request |
| `provider_switch` | 5 | Switching to a different provider |
| `combo_create` | 10 | Creating a new combo configuration |
| `combo_use` | 2 | Using a combo (per target hit) |
| `badge_earned` | 25 | Earning any badge |
| `streak_milestone` | 15 | Reaching a streak milestone (7, 14, 30, 60, 90, 180, 365) |
| `referral` | 50 | Successfully referring a new user |
| `token_share` | 5 | Sharing tokens with another user |
| `daily_login` | 3 | First request of the day |
| `model_diversity` | 3 | Using a model not used in the past 7 days |
| `compression_use` | 2 | Using prompt compression |
| `skill_use` | 2 | Executing a skill via MCP |
| Action | XP | Description |
| ----------------- | --- | -------------------------------------------------------- |
| `request` | 1 | Per API request routed through OmniRoute |
| `provider_switch` | 5 | Switching to a different provider |
| `model_switch` | 3 | Switching to a different model |
| `combo_create` | 10 | Creating a new combo |
| `combo_use` | 2 | Using a combo for a request |
| `token_share` | 1 | Per 1 000 tokens shared with another user |
| `invite_redeem` | 50 | Redeeming an invite code |
| `daily_login` | 5 | Daily active usage (once per day) |
| `streak_bonus` | 2 | Per consecutive streak day (multiplied by streak length) |
| `badge_unlock` | 10 | Unlocking a badge |
### Award Flow
@@ -812,7 +810,7 @@ Route → CORS preflight → Body validation (Zod) → Auth (extractApiKey)
Registered in `open-sse/mcp-server/` alongside existing tools. Scoped under
the `gamification` permission scope.
| Tool | Description | Input Schema |
| Tool | Description | Input Schema | |
| -------------------------- | ------------------------------------- | ---------------------------- | --------- |
| `gamification_leaderboard` | Get leaderboard for a scope/period | `{ scope, period?, limit? }` |
| `gamification_rank` | Get caller's rank and neighbors | `{ scope }` |

View File

@@ -1,6 +1,6 @@
# Free Tiers Guide: Understand and Combine Free AI Access
> **TL;DR**: OmniRoute registers 351 provider IDs, with **152 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **39 recurring pool keys / 445 entries** (438 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
> **TL;DR**: OmniRoute registers 352 provider IDs, with **152 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **34 recurring pool keys / 444 entries** (437 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
---
@@ -159,13 +159,13 @@ provider's quota or access policy.
The live, pool-deduplicated catalog currently reports:
| Metric | Current audited value | Interpretation |
| ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- |
| Recurring quantified grant | **~1.51B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.13B tokens** | Recurring total plus one-time and recurring credits |
| Audited free-model inventory | **39 recurring pool keys / 445 catalog entries** | 438 active + 7 discontinued; distinct from the 351-provider catalog |
| Recurring/keyless free-forever providers represented | **55** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types |
| Provider catalog entries marked `hasFree` | **152 / 351** | Broader provider metadata; not all have a quantifiable recurring quota |
| Metric | Current audited value | Interpretation |
| ---------------------------------------------------- | -----------------------------------------------: | -------------------------------------------------------------------------------------------------------------------------- |
| Recurring quantified grant | **~1.47B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.10B tokens** | Recurring total plus one-time and recurring credits |
| Audited free-model inventory | **34 recurring pool keys / 444 catalog entries** | 437 active + 7 discontinued; distinct from the 352-provider catalog |
| Recurring/keyless free-forever providers represented | **52** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types, eligibility-gated rows excluded |
| Provider catalog entries marked `hasFree` | **152 / 352** | Broader provider metadata; not all have a quantifiable recurring quota |
These values are computed from `open-sse/config/freeModelCatalog.ts`; see the
[Free Tiers Reference](../reference/FREE_TIERS.md) for pool deduplication, ToS flags,

View File

@@ -183,7 +183,7 @@ These providers offer **free access** with no credit card:
| **LongCat** | 10M one-time | LongCat-2.0 | API key + KYC |
| **Cloudflare AI** | 10K neurons/day | 50+ models | No auth needed |
| **NVIDIA NIM** | ~40 RPM | 129 models | API key needed |
| **Cerebras** | 1M tokens/day | Qwen3 235B, GPT-OSS 120B | API key needed |
| **Cerebras** | $5 signup credit | GLM 4.7, GPT-OSS 120B | API key + card |
| **Qoder** | Unlimited | Kimi-K2, DeepSeek-R1, Qwen3-coder | No auth needed |
**Tip**: Connect multiple free providers for **unlimited free AI** with automatic fallback!

View File

@@ -132,13 +132,40 @@ A bind mount is what makes the path trustworthy: OmniRoute reads
whose children are mounts, which is exactly the `/host-home` shape above) while
still refusing unmounted ones.
### Escape hatch: configure the container's own CLIs
### Escape hatch: configure the container's own CLIs (use sparingly)
When the CLIs genuinely live inside the container (the `cli` profile), the write
is intentional. Pass `--allow-container-write` to any `setup-*` command, or set
`OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` for the server. The write proceeds
with a warning that it will not survive the container.
> **Security warning — `cli` profile + `docker.sock` mount.**
> The `cli` profile bind-mounts `/var/run/docker.sock` so the in-container
> auto-updater can recreate the stack from the host daemon
> (`src/lib/system/autoUpdate.ts` probes for that socket and skips the
> Docker path when it is absent). That socket is **a host-root trust
> boundary**: anything that can reach it drives the host Docker daemon as
> root — it can create, inspect, stop and remove any container on the host.
> Implications:
>
> 1. **Never expose the `cli` profile's port to the network.** Publish
> it on `127.0.0.1` (`ports: "127.0.0.1:${DASHBOARD_PORT:-20128}:..."`)
> — a LAN-reachable `cli` profile turns any dashboard-level RCE into
> full host compromise.
> 2. **Do not bind any extra host directories into the `cli` profile.**
> The Docker socket plus any further mount gives the container full
> read/write to your filesystem and host config. If you need a tool to
> see a project, run it locally with the CLI binary — do not mount it
> into the `cli` container.
>
> If you do not need in-container auto-update, leave the `cli` profile off
> (`COMPOSE_PROFILES=core,redis` or shorter). The other profiles do not
> mount the Docker socket.
>
> See `docs/security/MITM-TPROXY-DECRYPT.md` for the related threat model
> around MITM, and `docs/security/SUPPLY_CHAIN.md` for the
> `codex`/`claude-code`/`droid`/`openclaw` binary provenance chain.
## Redis Sidecar
OmniRoute relies on Redis to back the distributed rate limiter and shared cache. The `redis` service is **always defined** in `docker-compose.yml` (it has no profile gate) and starts alongside any other profile.
@@ -567,19 +594,23 @@ External Postgres / multi-writer HA is **not** a documented stock path. If you n
## Scale-out: N independent processes
One Node process is **one V8 heap**. Two overlapping ~3MiB / ~750k-token coding-agent `POST /v1/responses` (RTK + Caveman) abort that heap at ~12Gi (`FATAL ERROR: Reached heap limit`) and can OOM a 16Gi cgroup. See [#7849](https://github.com/diegosouzapw/OmniRoute/issues/7849). Heavyweight chat admission is gated by an auto-derived ingest byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES`, `src/shared/middleware/admissionBudget.ts`) sized from that same V8/cgroup ceiling -- it already scales itself to the process's real memory, so overriding it upward (or setting the legacy `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` request-count cap) on an already-sized process reintroduces the abort. Small chats, `/healthz`, `/v1/models`, and MCP are **not** in that cap.
One Node process is **one V8 heap**. Two overlapping ~3MiB / ~750k-token coding-agent `POST /v1/responses` (RTK + Caveman) abort that heap at ~12Gi (`FATAL ERROR: Reached heap limit`) and can OOM a 16Gi cgroup. See [#7849](https://github.com/diegosouzapw/OmniRoute/issues/7849). That measurement is a **memory-budget** warning, not a product hard-max of two concurrent long `/v1/responses`. Heavyweight chat admission is gated by an auto-derived ingest byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES`, `src/shared/middleware/admissionBudget.ts`) sized from that same V8/cgroup ceiling overriding it upward (or setting the legacy `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` request-count cap) on an already-sized process reintroduces the abort. Small chats, `/healthz`, `/v1/models`, and MCP are **not** in that cap.
To go beyond two concurrent **large** jobs **today**:
### One-process: more than two long `/v1/responses`
| Do | Do not |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Run **N containers/pods**, each with its **own** `DATA_DIR` / volume | Set `replicas > 1` against one SQLite file |
| Keep each instance at 12 heavy in-flight and 1216Gi cgroup | Give one process 8× RAM and `max=8` |
| Optional: `QUOTA_STORE_DRIVER=redis` + `QUOTA_STORE_REDIS_URL` for **shared quota counters** | Treat Redis as shared SQLite — it is not |
| Duplicate provider secrets into each instance (or accept partitioned dashboards) | Expect one dashboard / one call-log across instances |
| Front with any load balancer; sticky by API key or session is enough | Require a vendor-specific size-aware middleware |
A **healthy** process (heap below `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO`, default `0.75`) **may** run more than two concurrent long `POST /v1/responses` when the process-wide inflight-byte budget (`OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` / #10110) still has room. Bodies at or above `OMNIROUTE_CHAT_LARGE_BODY_BYTES` (default 256KiB) take the same heavyweight lease as structure-heavy requests and use the same [#10437](https://github.com/diegosouzapw/OmniRoute/pull/10437) `tryAcquireHealthyHeadroom` escape (`OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM`). Tens of concurrent long SSE clients (operators often need 4050) is a **memory-budget** question — size heap + primary/headroom slots + `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` — not a hard “max 2” product limit. A pressured heap still sheds with retryable `503` so #7849 does not return.
Hardware: `concurrent_large ≈ N × 2` at ~812Gi heap / ~1216Gi cgroup **per instance**. Host RAM must cover `N × cgroup`, not “one 16Gi pod with N=8.”
To **multiply heaps** (independent V8 old-spaces) **today**:
| Do | Do not |
| --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Run **N containers/pods**, each with its **own** `DATA_DIR` / volume | Set `replicas > 1` against one SQLite file |
| Size heavy in-flight + healthy-headroom from heap / inflight-byte budget; 12 is the conservative #7849 default, not a hard product max | Give one process 8× RAM and an unbounded count cap |
| Optional: `QUOTA_STORE_DRIVER=redis` + `QUOTA_STORE_REDIS_URL` for **shared quota counters** | Treat Redis as shared SQLite — it is not |
| Duplicate provider secrets into each instance (or accept partitioned dashboards) | Expect one dashboard / one call-log across instances |
| Front with any load balancer; sticky by API key or session is enough | Require a vendor-specific size-aware middleware |
Hardware: per-instance concurrent long `/v1/responses` is a **memory-budget** question (heap + inflight-byte / #10110). `N` independent `DATA_DIR`s still multiply heaps: host RAM must cover `N × cgroup`, not “one 16Gi pod with N=8.” Never `replicas > 1` on one SQLite file.
Compose sketch (two heaps, two volumes — not `deploy.replicas: 2`):

View File

@@ -217,12 +217,12 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). |
| `DEFAULT_RATE_LIMIT_PER_DAY` | _(unset = unlimited)_ | `src/shared/utils/apiKeyPolicy.ts` | Fallback per-day request budget applied to API keys whose `rate_limits` column is null. Unset or empty: no implicit cap (#2289, #11017). `0` is the same (unlimited). Positive integer N enables N/day, 5N/week, 20N/month. Malformed non-empty values fall back to the legacy 1000/day, 5000/week, 20000/month windows. |
| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. |
| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. |
| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold take the atomic process-local heavyweight admission lease before JSON parsing (BYTE path, including `POST /v1/responses`). Same [#10437](https://github.com/diegosouzapw/OmniRoute/pull/10437) healthy-headroom escape as structure-heavy; still bounded by `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` / [#10110](https://github.com/diegosouzapw/OmniRoute/issues/10110) so [#7849](https://github.com/diegosouzapw/OmniRoute/issues/7849) does not return. |
| `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. |
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | _(unset — no request-count cap)_ | `src/shared/middleware/chatBodyAdmission.ts` | **#503-fanout:** this legacy request-COUNT cap now binds only when explicitly set. Left unset (the default), heavyweight chat admission is instead gated by `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` — an auto-derived BYTE budget sized from the process's real memory ceiling in **one process** (one V8 heap), fixing a bug where coding-agent fan-out (multiple subagents/CLIs, bodies routinely > 256 KB) collapsed to an effective concurrency of ~1 and 503'd under normal load. Setting this var restores the old fixed-count behavior on top of the byte budget for a deployment that already tuned it. Overload is retryable `503` with `Retry-After`. Two overlapping ~750k-token `/v1/responses` already abort ~12 Gi heaps (#7849) — the byte budget accounts for that ceiling automatically, so raising this manually is no longer the recommended lever. Multiply capacity with **N independent `DATA_DIR`s** (#11024), not `replicas>1` on one SQLite file. |
| `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` | _(auto-derived)_ | `src/shared/middleware/admissionBudget.ts` | **#503-fanout:** override for the auto-derived ingest byte budget (25% of the tighter V8/cgroup memory ceiling divided by 8x transient amplification). Derived and explicit values clamp to 8 MiB2 GiB. A body larger than the effective budget fails immediately with `413 body_exceeds_budget`; contention between individually serviceable bodies remains retryable `503`. Read `chatAdmission.maxInflightBytes` / `budgetSource` / `pressureSeverity` at `/api/monitoring/health` before tuning. |
| `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO` | `0.75` | `src/shared/middleware/chatBodyAdmission.ts` | Heap-pressure shed ratio (`heapUsed / heap_size_limit`) for the structural admission gate (#10183, #10268). A second concurrent heavyweight request past `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is only shed with the retryable `503` when the heap is ALSO at or above this ratio; on a healthy heap it is admitted instead. |
| `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM` | `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`) | `src/shared/middleware/chatBodyAdmission.ts` | Bounded extra capacity for the healthy-heap fast path above (#10437). Without this bound, every busy-but-healthy-heap request bypassed admission with no ceiling at all — a slow leak or a burst that never quite trips the heap-shed ratio could still pile up unlimited concurrent heavyweight work. Once this many concurrent leases are active through the healthy-heap path, further busy requests fall through to the SAME bounded-wait/shed path used under real heap pressure. `0` disables the bypass entirely. |
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | _(unset — no request-count cap)_ | `src/shared/middleware/chatBodyAdmission.ts` | **#503-fanout:** this legacy request-COUNT cap now binds only when explicitly set. Left unset (the default), heavyweight chat admission is instead gated by `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` — an auto-derived BYTE budget sized from the process's real memory ceiling in **one process** (one V8 heap). Two overlapping ~750k-token `/v1/responses` abort ~12Gi heaps (#7849) — a **memory-budget** warning, not a hard product max of 2. A healthy process (heap below the shed ratio) MAY admit more concurrent long `/v1/responses` via `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM`. Tens of long SSE clients (4050) is heap + `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` / #10110, not “max 2”. Blindly raising this to “use the host” reintroduces #7849. Multiply **heaps** with **N independent `DATA_DIR`s** (#11024); never `replicas>1` on one SQLite file. |
| `OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES` | _(auto-derived)_ | `src/shared/middleware/admissionBudget.ts` | **#503-fanout:** override for the auto-derived ingest byte budget (25% of the tighter V8/cgroup memory ceiling divided by 8x transient amplification). Derived and explicit values clamp to 8 MiB2 GiB. A body larger than the effective budget fails immediately with `413 body_exceeds_budget`; contention between individually serviceable bodies remains retryable `503`. 4050 concurrent long SSE clients is this budget + heap, not a hard “max 2”. Read `chatAdmission.maxInflightBytes` / `budgetSource` / `pressureSeverity` at `/api/monitoring/health` before tuning. |
| `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO` | `0.75` | `src/shared/middleware/chatBodyAdmission.ts` | Heap-pressure shed ratio (`heapUsed / heap_size_limit`) for BYTE and STRUCTURE heavyweight admission (#10183, #10268, #10437). A concurrent heavyweight request past `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is only shed with the retryable `503` when the heap is ALSO at or above this ratio; on a healthy heap it is admitted via healthy-headroom instead. |
| `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM` | `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`) | `src/shared/middleware/chatBodyAdmission.ts` | Bounded extra capacity for the healthy-heap fast path (#10437) on **both** STRUCTURE and BYTE (`admitChatRequest`, including bodies ≥ `OMNIROUTE_CHAT_LARGE_BODY_BYTES`). Without this bound, every busy-but-healthy-heap request bypassed admission with no ceiling. Once this many concurrent leases are active through the healthy-heap path, further busy requests fall through to the SAME bounded-wait/shed path used under real heap pressure. `0` disables the bypass entirely. |
| `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. |
@@ -636,6 +636,8 @@ process.env[`${PROVIDER_ID}_USER_AGENT`]
| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. |
| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI |
| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string |
| `CLAUDE_CODE_CLIENT_VERSION` | `2.1.258` | Override advertised Claude Code version independently of `CLAUDE_USER_AGENT`. Anthropic gates some models on this value (#12417). |
| `GITHUB_COPILOT_CLI_VERSION` | `1.0.81-6` | Override advertised Copilot CLI version independently of `GITHUB_USER_AGENT` |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | When GitHub Copilot Chat updates |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates |
@@ -1041,6 +1043,7 @@ desktop install.
| ---------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. |
| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. |
| `CATALOG_BUILD_TIMEOUT_MS` | `8000` (8s) | `src/app/api/v1/models/catalogCache.ts` | Cold-path wait bound for a coalesced `GET /v1/models` catalog rebuild (#12627). On timeout, a last-good 200 is served when one exists. |
| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. |
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. |

View File

@@ -1,35 +1,40 @@
---
title: "Free Tiers & Free-Token Budget"
version: 3.8.50
lastUpdated: 2026-08-31
lastUpdated: 2026-09-03
---
# Free Tiers & Free-Token Budget
> **For Users**: Looking for a simple guide? See the [Free Tiers Guide](../getting-started/FREE-TIERS-GUIDE.md) for step-by-step instructions on getting free AI.
> **Last researched:** 2026-06-17 — per-provider web research (official docs + last-7-days news, 50-agent pass with adversarial verification) refreshing every free-tier quota + ToS.
> **Last researched:** 2026-06-17 — per-provider web research (official docs + last-7-days news, 50-agent pass with adversarial verification) refreshing every free-tier quota + ToS. **Partial re-audit 2026-09-02** (`gemini`, `ollama-cloud`, `groq`, `nara`, `mistral` — see the dated note below).
> **Source of truth (catalog):** `open-sse/config/freeModelCatalog.ts` (per-MODEL budgets, pool-deduped). The token-budget numbers below come from live web research and are an **approximation** — see [Methodology & caveats](#methodology--caveats).
## TL;DR — how much free inference does OmniRoute actually aggregate?
| Metric | Tokens / month | Meaning |
| ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Documented recurring grant (steady)** | **~1.51B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** |
| **+ first month with signup credits** | **~2.13B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. |
| **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). |
| **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. |
| Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. |
| Metric | Tokens / month | Meaning |
| ------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Documented recurring grant (steady)** | **~1.47B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** |
| **+ first month with signup credits** | **~2.10B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. |
| **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`, `gemini`, `ollama-cloud` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). |
| **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. |
| **+ behind a regional identity check** | **+~6M** | `modelscope` (Alibaba Cloud binding + mainland-China real-name verification). Real recurring quota, exposed as `gatedRecurringTokens` / `gatedProviders` and on the dashboard. Never summed into the headline: +~6M behind regional identity verification. |
| Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. |
**Honest headline:** _OmniRoute aggregates **~1.51B documented free tokens per month** (up to ~2.13B in your first month with signup credits) across 38 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (1595% token savings) stretches that further._
**Honest headline:** _OmniRoute aggregates **~1.47B documented free tokens per month** (up to ~2.10B in your first month with signup credits) across 34 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (1595% token savings) stretches that further._
> **Why this dropped from the previous ~1.94B.** The 2026-06-17 refresh is an honesty correction, not a loss: `gemini` is now pool-deduped (was inflated by counting each Flash variant separately, 462M → 60M), `cloudflare-ai` corrected to its real 10k-Neurons/day (122M → 30M), `doubao` reclassified as a one-time signup credit (not recurring), and shut-down tiers removed (`chutes`/`phind`/`kluster` discontinued). Partly offset by `llm7` (correct 5M/day → 150M) and new free providers (Kilo, OpenCode Zen, Z.AI GLM-Flash).
>
> **Further corrected to ~1.37B in v3.8.42:** `longcat` was reclassified from a 150M/mo recurring grant to a one-time 10M signup credit after its free preview ended. Same honesty rule — no provider was dropped by mistake.
>
> **Updated on 2026-08-26 after retiring Felo Web:** the source now reports 38 recurring pool keys. Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD. This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`).
> **Updated on 2026-08-26 after retiring Felo Web:** Felo Web is excluded while its GPL-derived provenance/licensing remains on HOLD; the source reported 38 pool keys at the time. The pool count is live and CI-gated (`check:docs-counts` fails the build if the numbers above drift from `computeFreeModelTotals()`).
>
> **Re-audited on 2026-09-02 against the providers' own pages** (sources: the `// evidence:` comments next to each re-audited entry in `open-sse/config/freeModelCatalog.data.ts`): `gemini` and `ollama-cloud` no longer publish a token figure (Google removed the per-model free table on 2025-12-23; Ollama's Free plan is "starter usage credits") and are now listed as **uncapped**, never summed (80M); `groq` is five **per-model** 200K-TPD caps (6M each, +15M) with three retired IDs dropped; `nara` is one 7M/day bucket (+60M, 210M). `mistral`'s 1B is visible only in the account console — see _Evidence classes_ under Methodology. The source reported 35 such keys at that point (3: `gemini` and `ollama-cloud` moved to the uncapped list, and Groq's per-model caps are not a shared pool).
>
> **Corrected to ~1.47B on 2026-09-03 (#11773):** `cerebras` was reclassified from a 30M/mo recurring grant (old no-card 1M tokens/day trial) to a one-time $5 signup credit that requires a payment method. Same honesty rule as LongCat. The source now reports 34 recurring pool keys and ~1.47B steady.
Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `nara` 150M, `gemini` 60M, `cerebras` 30M, `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
Biggest **documented** contributors: `mistral` 1.00B, `nara` 210M, `llm7` 150M, `groq` 30M (five per-model caps), `cloudflare-ai` 30M, `api-airforce` 24M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.)
> ⚠️ The theoretical ceiling (~10B) is inflated by rate-limit-only providers with **no published token cap** (`tencent`, `siliconflow`, `nvidia`, `baidu`, `glm-cn`, `sparkdesk`) whose figures would be `RPM/TPM × 24/7 × 30d` — a theoretical maximum no single account will sustain. They are **excluded** from the defensible number (shown in the "permanently free, no cap" row instead). This is the same inflation that makes competitors' multi-billion claims unreliable.
@@ -69,11 +74,25 @@ purpose.
## Methodology & caveats
- Numbers are **upper-bound estimates** from each provider's documented free-tier limits as of **2026-06-17**, gathered by web research. Free tiers change constantly — re-verify before relying on a figure.
- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 7 entries carry an independently documented hard stop, and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you".
- **What an entry actually vouches for.** No entry carries a per-row confidence rating, and the API serves none — treat every figure above as an estimate of the same, unstated quality. Two facts are different, because they are curated by hand rather than inferred: 5 entries carry an independently documented hard stop, and 13 entries carry a prompt-training disclosure. `hardStopGuaranteed` is set only when the provider's own terms say that exceeding the free allowance refuses the request rather than silently starting to bill you, with the source in a comment next to the entry; it is never defaulted to `true`, and an entry nobody has verified stays unset. So a missing hard-stop flag means "not established", not "known to bill you".
- `estMonthlyFreeTokens` = recurring monthly tokens only. **One-time signup credits do not recur** and count as 0. Discontinued tiers are also 0.
- Daily token cap → `monthly = daily × 30`. Only RPD documented → `RPD × ~800 output tokens × 30`. Only RPM/TPM (no daily cap) → **uncapped** (see below).
- **Permanently free, but no published token cap** (`siliconflow`, `glm-cn`, `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`): these are real recurring free access, rate/concurrency-limited. We classify them `recurring-uncapped` and **never sum them** — multiplying `RPM × 24/7 × 30d` would produce a fantasy ceiling (the inflation we reject). They are listed so you know they exist.
- **Permanently free, but no published token cap** (`siliconflow`, `glm-cn`, `tencent`, `baidu`, `kilo-gateway`, `opencode-zen`, `gemini`, `ollama-cloud`): these are real recurring free access, rate/concurrency-limited. We classify them `recurring-uncapped` and **never sum them** — multiplying `RPM × 24/7 × 30d` would produce a fantasy ceiling (the inflation we reject). They are listed so you know they exist.
- **Deposit-unlock boost:** a one-time small top-up that permanently raises a free quota (OpenRouter: $10 → 1000 req/day ≈ +24M/mo). Reported as a separate figure, kept out of the steady headline.
- **Eligibility-gated quotas** (`eligibilityGate: "regional-identity"`): a real recurring quota that only opens after a region-bound identity check (mainland-China real-name verification today). Counted with the same pool-dedupe rule into a separate figure (`gatedRecurringTokens`), never into the steady headline. The regime (`freeType`) is unchanged, so routing is unchanged.
- **Evidence classes.** The rule: a number in the catalog cites its source in an `// evidence:` comment next to the entry — `public-page` (a provider page anyone can read), `api-public` (an unauthenticated endpoint of the provider, e.g. NaraRouter's public plans endpoint at router.bynara.id), or `console-verified <date> por <who>` (the figure is only visible inside an account console; the comment records who saw it and when, and the public page that says the cap exists). The state today: the five blocks re-audited on 2026-09-02 carry it (`gemini`, `groq`, `mistral`, `ollama-cloud`, `nara`); entries that predate the 2026-09-02 re-audit inherit the earlier research until they are touched; any **new or changed** number without an evidence comment is a bug. Today only `mistral` is console-verified.
---
## Why our number is smaller than other aggregators'
Most "free tokens per month" figures in this space are sums of per-model labels. Ours is not, on purpose:
- **Each shared pool is counted once.** Mistral's free plan is one 1B/month allowance per organization; listing it under five models does not make it 5B. Summed per model, our own catalog would read **~7.4B** (recomputed on 2026-09-03; this figure is not CI-gated — re-measure it whenever the catalog changes) — the headline says **~1.47B** because that is what one account of each provider can actually spend.
- **Daily caps are converted, rates are not.** A documented tokens/day cap becomes `× 30`; a documented requests/day cap becomes `RPD × ~800 tokens × 30`; a provider that only publishes requests-per-minute has **no** monthly figure and is listed as _uncapped_, never summed. Multiplying a rate limit by 24/7 is the inflation we refuse.
- **Quotas behind a regional identity check are shown apart** (`+~6M behind regional identity verification`), because most readers cannot use them.
- **Signup credits are first-month only** and reported as a second figure, never blended into the steady number.
- **The figure is enforced by CI.** `npm run check:docs-counts` recomputes the totals from the catalog and fails the build when this file, the README or the budget card drift from them.
---
@@ -183,21 +202,20 @@ purpose.
---
## Per-provider free-tier (refreshed 2026-06-17)
## Per-provider free-tier (refreshed 2026-09-02 for the re-audited rows; 2026-06-17 otherwise)
> Regenerated from the per-model catalog (`open-sse/config/freeModelCatalog.ts`), pool-deduped. Sorted by recurring steady tokens/mo. `uncapped*` = permanently free but no published token cap (rate/concurrency-limited) — real access, **not** summed into the headline. `—` = credit-only / keyless / not token-quantifiable.
| Provider | Free type | Steady tokens/mo | First-month credit | ToS | Models |
| ---------------- | ------------- | ---------------- | ------------------ | --------- | ------ |
| `mistral` | recurring | ~1.00B | — | caution | 5 |
| `nara` | recurring | ~210M | — | caution | 8 |
| `llm7` | recurring | ~150M | — | caution | 4 |
| `longcat` | one-time | — | 10M | caution | 1 |
| `gemini` | recurring | ~60M | | caution | 4 |
| `cerebras` | recurring | ~30M | — | caution | 2 |
| `cerebras` | one-time | | $5 credit | caution | 2 |
| `cloudflare-ai` | recurring | ~30M | — | caution | 9 |
| `groq` | recurring | ~30M | — | caution | 5 |
| `api-airforce` | recurring | ~24M | — | caution | 7 |
| `ollama-cloud` | recurring | ~20M | — | ambiguous | 8 |
| `groq` | recurring | ~15M | — | caution | 5 |
| `bluesminds` | recurring | ~7M | — | ambiguous | 22 |
| `sambanova` | recurring | ~6M | — | caution | 5 |
| `arcee-ai` | recurring | ~5M | — | caution | 1 |
@@ -210,7 +228,9 @@ purpose.
| `kiro` | recurring | ~25K | — | avoid | 12 |
| `glm-cn` | uncapped | uncapped\* | ~20M | ok | 4 |
| `baidu` | uncapped | uncapped\* | — | caution | 1 |
| `gemini` | uncapped | uncapped\* | — | caution | 4 |
| `kilo-gateway` | uncapped | uncapped\* | — | caution | 7 |
| `ollama-cloud` | uncapped | uncapped\* | — | ambiguous | 8 |
| `opencode-zen` | uncapped | uncapped\* | — | caution | 6 |
| `siliconflow` | uncapped | uncapped\* | — | caution | 10 |
| `tencent` | uncapped | uncapped\* | — | caution | 1 |
@@ -276,7 +296,7 @@ purpose.
- **`bluesminds`** — Our shipped freeNote was "(none)" — but BluesMinds does have a documented free tier: 500 pi credits, 20 RPM, 300 RPD, permanent free plan. The catalog significantly understates the offering.
- **`brave-search`** — The catalog notes "(none)" suggesting no free tier was tracked, but in reality there was a free 5,000 queries/month tier (no card) until February 12, 2026, which has since been replaced by a $5/month…
- **`byteplus`** — Our catalog shipped "(none)" but BytePlus ModelArk does have a free tier: a one-time trial credit of 500k tokens per LLM model for new accounts. The catalog underreports this.
- **`cerebras`** — TPM appears tightened from 60K to 30K on current documented models (gpt-oss-120b, zai-glm-4.7). RPM of 5 is now explicitly documented (was not in our shipped note). Daily token cap of 1M/day is uncha…
- **`cerebras`** — The no-card 1M tokens/day trial is gone. Live cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit, payment method required, 30-day validity. Reclassified as `one-time-initial` (LongCat-shaped); dropped from `LEGACY_FREE_PROVIDERS` and the recurring budget.
- **`chutes`** — The shipped freeNote says "Free tier available" but as of March 15, 2026, the free tier has been officially discontinued. The catalog note is stale and should be updated to reflect that there is no r…
- **`coze`** — The shipped note "Free ByteDance agent platform" is directionally accurate but omits that the free tier is now tightly credit-capped (10 credits/day ≈ 5100 messages depending on model), a constraint…
- **`deepinfra`** — Our shipped freeNote says "Free signup credits for API testing" — this appears stale. The official pricing page now requires card/prepayment with no documented general free signup credit. The free ti…
@@ -292,7 +312,7 @@ purpose.
- **`gemini`** — The shipped freeNote says "1,500 req/day for Gemini 2.5 Flash" — this was accurate before December 2025. Google cut free-tier limits by 50-80% in December 2025, reducing Gemini 2.5 Flash from 1,500 R…
- **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U…
- **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders…
- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific …
- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific … **Resolved 2026-09-02:** the `freeNote` now reads "Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file." and the catalog carries five per-model 6M caps (llama-3.3-70b-versatile retired from the free tier on 2026-08-16) — see the [2026-09-02 re-audit note](#tldr--how-much-free-inference-does-omniroute-actually-aggregate).
- **`huggingchat`** — The shipped freeNote ("Free LLM chat — no subscription required. Rate limits apply.") is partially accurate but significantly understates the restrictions. The free tier now operates on a hard $0.10/…
- **`huggingface`** — Significantly tightened. The shipped freeNote ("Free Inference API for thousands of models") implied unlimited/generous free access, but as of mid-2025 the free tier is capped at $0.10/month in recur…
- **`hyperbolic`** — Our shipped freeNote says "$1-5 trial credits on signup" — the $1 trial credit portion is accurate, but the "$5" figure refers to the minimum deposit required to unlock GPU rental (not free credits g…

View File

@@ -151,7 +151,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 |
| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — |
| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | One-time $5 signup credit (30-day validity); a payment method is required. Not a recurring free tier. |
| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup |
| `chat-oripe` | `chat-oripe` | Chat Oripe | API key, aggregator | [link](https://api.oriper.com) | Official metadata advertises 2M tokens/month, but the public site and documentation were blocked during audit; treat the quota and brand mapping as unconfirmed. |
| `chatanywhere` | `chatanywhere` | ChatAnywhere | API key, aggregator | [link](https://chatanywhere.tech) | Personal, educational or research use only: public documentation cites 10,000 points/day and 200 requests/day per IP/key; do not use for commercial traffic. |
@@ -210,7 +210,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — |
| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — |
| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — |
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card |
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file. |
| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api |
| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn |
| `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. |
@@ -260,7 +260,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `naga-ac` | `naga` | Naga.ac | API key, aggregator | [link](https://naga.ac) | Get API key at naga.ac — Google/GitHub/Discord signup available. |
| `naga-ai` | `naga-ai` | Naga AI | API key, aggregator | [link](https://naga.ac) | Models marked :free are publicly listed, but no numeric quota is confirmed. Naga's policy warns that free-tier prompts and outputs may be collected or used for training. |
| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — |
| `nara` | `nara` | NaraRouter | API key | [link](https://bynara.id) | Get a free API key via NaraRouter's Telegram channel, then paste it here as a Bearer token. |
| `nara` | `nara` | NaraRouter | API key | [link](https://bynara.id) | Create a free NaraRouter account, link your Telegram (required before /v1 answers), then paste the key here as a Bearer token. |
| `navy` | `navy` | NavyAI | API key | [link](https://api.navy) | Create a free API key from the NavyAI dashboard, then paste it here as a Bearer token. |
| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing |
| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token <key>. OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu/<model>/chatbot by default. |
@@ -444,7 +444,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
- Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts)
- Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts)
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (108 implementations)
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (109 implementations)
- Translators: [`open-sse/translator/`](../../open-sse/translator/)
## See Also

View File

@@ -1,94 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="536" viewBox="0 0 900 536" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
<rect width="900" height="536" rx="16" fill="#0d1117"/>
<rect x="16" y="16" width="868" height="520" rx="13" fill="#161b22" stroke="#30363d"/>
<text x="868" y="528" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
<rect width="900" height="580" rx="16" fill="#0d1117"/>
<rect x="16" y="16" width="868" height="564" rx="13" fill="#161b22" stroke="#30363d"/>
<text x="868" y="572" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>
<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">20 free pools · 446 models · one endpoint</text>
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">21 free pools · 444 models · one endpoint</text>
<text x="32" y="84" fill="#7d8590" font-size="11.5">Steady / month</text>
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.51B</text>
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.47B</text>
<text x="330" y="84" fill="#7d8590" font-size="11.5">First month (+ signup credits)</text>
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.13B</text>
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.10B</text>
<text x="700" y="84" fill="#7d8590" font-size="11.5">ToS-flagged (you decide)</text>
<text x="700" y="114" fill="#d29922" font-size="27" font-weight="800">13 providers</text>
<clipPath id="bar"><rect x="32" y="132" width="836" height="16" rx="8"/></clipPath>
<g clip-path="url(#bar)"><rect x="32" y="132" width="836" height="16" fill="#21262d"/>
<rect x="32.0" y="132" width="492.8" height="16" fill="#6c5ce7"/>
<rect x="524.2" y="132" width="80.4" height="16" fill="#00b894"/>
<rect x="604.0" y="132" width="80.4" height="16" fill="#0984e3"/>
<rect x="683.8" y="132" width="36.7" height="16" fill="#e17055"/>
<rect x="719.9" y="132" width="22.2" height="16" fill="#fdcb6e"/>
<rect x="741.5" y="132" width="19.2" height="16" fill="#e84393"/>
<rect x="760.1" y="132" width="17.3" height="16" fill="#00cec9"/>
<rect x="776.8" y="132" width="11.1" height="16" fill="#d63031"/>
<rect x="787.3" y="132" width="10.5" height="16" fill="#a29bfe"/>
<rect x="797.2" y="132" width="9.9" height="16" fill="#55efc4"/>
<rect x="806.6" y="132" width="9.8" height="16" fill="#74b9ff"/>
<rect x="815.7" y="132" width="9.3" height="16" fill="#ffeaa7"/>
<rect x="824.5" y="132" width="8.2" height="16" fill="#fab1a0"/>
<rect x="832.1" y="132" width="8.0" height="16" fill="#81ecec"/>
<rect x="839.5" y="132" width="7.8" height="16" fill="#6c5ce7"/>
<rect x="846.7" y="132" width="7.8" height="16" fill="#00b894"/>
<rect x="853.9" y="132" width="7.7" height="16" fill="#0984e3"/>
<rect x="861.0" y="132" width="7.6" height="16" fill="#e17055"/>
<rect x="32.0" y="132" width="475.3" height="16" fill="#6c5ce7"/>
<rect x="506.7" y="132" width="105.8" height="16" fill="#00b894"/>
<rect x="611.9" y="132" width="77.8" height="16" fill="#0984e3"/>
<rect x="689.0" y="132" width="21.6" height="16" fill="#e17055"/>
<rect x="710.1" y="132" width="18.8" height="16" fill="#fdcb6e"/>
<rect x="728.3" y="132" width="11.0" height="16" fill="#e84393"/>
<rect x="738.7" y="132" width="10.4" height="16" fill="#00cec9"/>
<rect x="748.5" y="132" width="10.4" height="16" fill="#d63031"/>
<rect x="758.3" y="132" width="10.4" height="16" fill="#a29bfe"/>
<rect x="768.1" y="132" width="10.4" height="16" fill="#55efc4"/>
<rect x="777.9" y="132" width="10.4" height="16" fill="#74b9ff"/>
<rect x="787.7" y="132" width="10.4" height="16" fill="#ffeaa7"/>
<rect x="797.5" y="132" width="9.8" height="16" fill="#fab1a0"/>
<rect x="806.8" y="132" width="9.7" height="16" fill="#81ecec"/>
<rect x="815.9" y="132" width="9.3" height="16" fill="#6c5ce7"/>
<rect x="824.5" y="132" width="8.2" height="16" fill="#00b894"/>
<rect x="832.1" y="132" width="8.0" height="16" fill="#0984e3"/>
<rect x="839.5" y="132" width="7.8" height="16" fill="#e17055"/>
<rect x="846.7" y="132" width="7.8" height="16" fill="#fdcb6e"/>
<rect x="853.9" y="132" width="7.7" height="16" fill="#e84393"/>
<rect x="861.0" y="132" width="7.6" height="16" fill="#00cec9"/>
</g>
<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>
<circle cx="37" cy="196" r="5" fill="#6c5ce7"/>
<text x="48" y="200" fill="#c9d1d9" font-size="12.5">Mistral Large 3 <tspan fill="#7d8590">1.00B</tspan></text>
<circle cx="250" cy="196" r="5" fill="#00b894"/>
<text x="261" y="200" fill="#c9d1d9" font-size="12.5">GPT-4o mini <tspan fill="#7d8590">150M</tspan></text>
<text x="261" y="200" fill="#c9d1d9" font-size="12.5">Agnes 2.0 Flash <tspan fill="#7d8590">210M</tspan></text>
<circle cx="463" cy="196" r="5" fill="#0984e3"/>
<text x="474" y="200" fill="#c9d1d9" font-size="12.5">Tencent Hy3 <tspan fill="#7d8590">150M</tspan></text>
<text x="474" y="200" fill="#c9d1d9" font-size="12.5">GPT-4o mini <tspan fill="#7d8590">150M</tspan></text>
<circle cx="676" cy="196" r="5" fill="#e17055"/>
<text x="687" y="200" fill="#c9d1d9" font-size="12.5">Gemini 2.5 Flash <tspan fill="#7d8590">60M</tspan></text>
<text x="687" y="200" fill="#c9d1d9" font-size="12.5">Llama 3.3 70B <tspan fill="#7d8590">30M</tspan></text>
<circle cx="37" cy="226" r="5" fill="#fdcb6e"/>
<text x="48" y="230" fill="#c9d1d9" font-size="12.5">Llama 3.3 70B <tspan fill="#7d8590">30M</tspan></text>
<text x="48" y="230" fill="#c9d1d9" font-size="12.5">Grok-3 <tspan fill="#7d8590">24M</tspan></text>
<circle cx="250" cy="226" r="5" fill="#e84393"/>
<text x="261" y="230" fill="#c9d1d9" font-size="12.5">Grok-3 <tspan fill="#7d8590">24M</tspan></text>
<text x="261" y="230" fill="#c9d1d9" font-size="12.5">GPT-4o <tspan fill="#7d8590">7M</tspan></text>
<circle cx="463" cy="226" r="5" fill="#00cec9"/>
<text x="474" y="230" fill="#c9d1d9" font-size="12.5">DeepSeek V4 Pro <tspan fill="#7d8590">20M</tspan></text>
<text x="474" y="230" fill="#c9d1d9" font-size="12.5">GPT-OSS 120B <tspan fill="#7d8590">6M</tspan></text>
<circle cx="676" cy="226" r="5" fill="#d63031"/>
<text x="687" y="230" fill="#c9d1d9" font-size="12.5">GPT-4o <tspan fill="#7d8590">7M</tspan></text>
<text x="687" y="230" fill="#c9d1d9" font-size="12.5">GPT-OSS 20B <tspan fill="#7d8590">6M</tspan></text>
<circle cx="37" cy="256" r="5" fill="#a29bfe"/>
<text x="48" y="260" fill="#c9d1d9" font-size="12.5">MiniMax-M2.7 <tspan fill="#7d8590">6M</tspan></text>
<text x="48" y="260" fill="#c9d1d9" font-size="12.5">GPT-OSS Safeguard 20B <tspan fill="#7d8590">6M</tspan></text>
<circle cx="250" cy="256" r="5" fill="#55efc4"/>
<text x="261" y="260" fill="#c9d1d9" font-size="12.5">Arcee Trinity Large Prev <tspan fill="#7d8590">5M</tspan></text>
<text x="261" y="260" fill="#c9d1d9" font-size="12.5">Qwen3.6 27B <tspan fill="#7d8590">6M</tspan></text>
<circle cx="463" cy="256" r="5" fill="#74b9ff"/>
<text x="474" y="260" fill="#c9d1d9" font-size="12.5">NavyAI free pool <tspan fill="#7d8590">5M</tspan></text>
<text x="474" y="260" fill="#c9d1d9" font-size="12.5">Qwen3.8 27B <tspan fill="#7d8590">6M</tspan></text>
<circle cx="676" cy="256" r="5" fill="#ffeaa7"/>
<text x="687" y="260" fill="#c9d1d9" font-size="12.5">Auto Free <tspan fill="#7d8590">4M</tspan></text>
<text x="687" y="260" fill="#c9d1d9" font-size="12.5">MiniMax-M2.7 <tspan fill="#7d8590">6M</tspan></text>
<circle cx="37" cy="286" r="5" fill="#fab1a0"/>
<text x="48" y="290" fill="#c9d1d9" font-size="12.5">Auto <tspan fill="#7d8590">1M</tspan></text>
<text x="48" y="290" fill="#c9d1d9" font-size="12.5">Arcee Trinity Large Prev <tspan fill="#7d8590">5M</tspan></text>
<circle cx="250" cy="286" r="5" fill="#81ecec"/>
<text x="261" y="290" fill="#c9d1d9" font-size="12.5">Command A Reasoning <tspan fill="#7d8590">800K</tspan></text>
<text x="261" y="290" fill="#c9d1d9" font-size="12.5">NavyAI free pool <tspan fill="#7d8590">5M</tspan></text>
<circle cx="463" cy="286" r="5" fill="#6c5ce7"/>
<text x="474" y="290" fill="#c9d1d9" font-size="12.5">ERNIE 4.5 VL 424B A47B B <tspan fill="#7d8590">500K</tspan></text>
<text x="474" y="290" fill="#c9d1d9" font-size="12.5">Auto Free <tspan fill="#7d8590">4M</tspan></text>
<circle cx="676" cy="286" r="5" fill="#00b894"/>
<text x="687" y="290" fill="#c9d1d9" font-size="12.5">morph-v3-large <tspan fill="#7d8590">400K</tspan></text>
<text x="687" y="290" fill="#c9d1d9" font-size="12.5">Auto <tspan fill="#7d8590">1M</tspan></text>
<circle cx="37" cy="316" r="5" fill="#0984e3"/>
<text x="48" y="320" fill="#c9d1d9" font-size="12.5">Llama 3.1 8B <tspan fill="#7d8590">200K</tspan></text>
<text x="48" y="320" fill="#c9d1d9" font-size="12.5">Command A Reasoning <tspan fill="#7d8590">800K</tspan></text>
<circle cx="250" cy="316" r="5" fill="#e17055"/>
<text x="261" y="320" fill="#c9d1d9" font-size="12.5">Claude Sonnet 4.5 <tspan fill="#7d8590">25K</tspan></text>
<line x1="32" y1="356" x2="868" y2="356" stroke="#30363d"/>
<text x="32" y="382" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~626M)</text>
<rect x="32" y="391" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="77" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">vertex 300M</text>
<rect x="130" y="391" width="123" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="191" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">agentrouter 200M</text>
<rect x="261" y="391" width="103" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="312" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">predibase 25M</text>
<rect x="372" y="391" width="96" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="420" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">together 25M</text>
<rect x="476" y="391" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="518" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">glm-cn 20M</text>
<rect x="567" y="391" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="609" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">doubao 15M</text>
<rect x="658" y="391" width="70" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="693" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">ai21 10M</text>
<rect x="736" y="391" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="781" y="406" fill="#7ee787" font-size="11.5" text-anchor="middle">longcat 10M</text>
<text x="261" y="320" fill="#c9d1d9" font-size="12.5">ERNIE 4.5 VL 424B A47B B <tspan fill="#7d8590">500K</tspan></text>
<circle cx="463" cy="316" r="5" fill="#fdcb6e"/>
<text x="474" y="320" fill="#c9d1d9" font-size="12.5">morph-v3-large <tspan fill="#7d8590">400K</tspan></text>
<circle cx="676" cy="316" r="5" fill="#e84393"/>
<text x="687" y="320" fill="#c9d1d9" font-size="12.5">Llama 3.1 8B <tspan fill="#7d8590">200K</tspan></text>
<circle cx="37" cy="346" r="5" fill="#00cec9"/>
<text x="48" y="350" fill="#c9d1d9" font-size="12.5">Claude Sonnet 4.5 <tspan fill="#7d8590">25K</tspan></text>
<line x1="32" y1="386" x2="868" y2="386" stroke="#30363d"/>
<text x="32" y="412" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~626M)</text>
<rect x="32" y="421" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="77" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">deepseek 5M</text>
<rect x="32" y="462" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>
<text x="46" y="476" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>
<text x="46" y="490" fill="#7d8590" font-size="11.5">+ 12 permanently-free, no-cap providers (e.g. baidu, glm-cn, opencode-zen) · OpenRouter $10 → +24M/mo.</text>
<text x="77" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">vertex 300M</text>
<rect x="130" y="421" width="123" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="191" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">agentrouter 200M</text>
<rect x="261" y="421" width="103" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="312" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">predibase 25M</text>
<rect x="372" y="421" width="96" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="420" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">together 25M</text>
<rect x="476" y="421" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="518" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">glm-cn 20M</text>
<rect x="567" y="421" width="83" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="609" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">doubao 15M</text>
<rect x="658" y="421" width="70" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="693" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">ai21 10M</text>
<rect x="736" y="421" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="781" y="436" fill="#7ee787" font-size="11.5" text-anchor="middle">longcat 10M</text>
<rect x="32" y="451" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
<text x="77" y="466" fill="#7ee787" font-size="11.5" text-anchor="middle">deepseek 5M</text>
<rect x="32" y="492" width="836" height="48" rx="8" fill="#1c2230" stroke="#30363d"/>
<text x="46" y="506" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>
<text x="46" y="520" fill="#7d8590" font-size="11.5">+ 15 permanently-free, no-cap providers (e.g. agnes, ainative, aion) · OpenRouter $10 → +24M/mo.</text>
<text x="46" y="534" fill="#d29922" font-size="11.5">+ ~6M behind regional identity verification (modelscope) — real quota, never in the headline.</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -4,6 +4,8 @@ import {
CLAUDE_CODE_CLIENT_VERSION,
CLAUDE_CODE_RUNTIME_VERSION,
CLAUDE_CODE_SDK_PACKAGE_VERSION,
getClaudeCodeClientBillingVersion,
getClaudeCodeClientVersion,
getClaudeCodeUserAgent,
} from "@/shared/constants/claudeCodeClient";
import { modelSupportsContext1mBeta } from "../config/context1m.ts";
@@ -166,8 +168,17 @@ export function normalizeAnthropicHeaderVariants(headers: Record<string, string>
}
export const CLAUDE_CLI_VERSION = CLAUDE_CODE_CLIENT_VERSION;
export function getClaudeCliVersion(): string {
return getClaudeCodeClientVersion();
}
export const CLAUDE_CLI_BUILD_REVISION = CLAUDE_CODE_CLIENT_BUILD_REVISION;
/** Captured-pin snapshot. Wire billing uses getClaudeCliBillingVersion(). */
export const CLAUDE_CLI_BILLING_VERSION = CLAUDE_CODE_CLIENT_BILLING_VERSION;
export function getClaudeCliBillingVersion(): string {
return getClaudeCodeClientBillingVersion();
}
/** Module-load snapshot of the pin (or env if set before import). Wire UA uses getClaudeCodeUserAgent(). */
export const CLAUDE_CLI_USER_AGENT = getClaudeCodeUserAgent("cli");
export { getClaudeCodeUserAgent };
export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION;
export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION;

View File

@@ -2,11 +2,17 @@ import {
CLAUDE_CODE_CLIENT_VERSION,
CLAUDE_CODE_RUNTIME_VERSION,
CLAUDE_CODE_SDK_PACKAGE_VERSION,
getClaudeCodeClientVersion,
getClaudeCodeUserAgent,
} from "@/shared/constants/claudeCodeClient";
export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CODE_CLIENT_VERSION;
export function getClaudeCodeCompatibleVersion(): string {
return getClaudeCodeClientVersion();
}
/** Module-load snapshot. Wire UA uses getClaudeCodeUserAgent("sdk-cli"). */
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli");
export { getClaudeCodeUserAgent };
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION;
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION;
const CONTEXT_1M_NATIVE_MODELS = ["claude-fable-5-1", "claude-opus-5"];

View File

@@ -12,7 +12,7 @@
import { isClaudeCodeCompatible } from "../services/provider.ts";
import {
getAntigravityUserAgent,
GITHUB_COPILOT_CHAT_USER_AGENT,
getGitHubCopilotChatUserAgent,
} from "./providerHeaderProfiles.ts";
import { normalizeCliCompatProviderId } from "@/shared/utils/cliCompat";
@@ -169,7 +169,7 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
"intent_threshold",
"intent_content",
],
userAgent: GITHUB_COPILOT_CHAT_USER_AGENT,
userAgent: getGitHubCopilotChatUserAgent,
},
antigravity: {
headerOrder: [

View File

@@ -1,12 +1,18 @@
// AUTO-GENERATED — refreshed by the 2026-06-17 per-provider free-tier research pass.
// 2026-07-20: added the free tiers of providers we could already route but had
// never mapped (requesty, ovhcloud, agnes, glm), plus two new providers (navy,
// aihorde), and reconciled kilo-gateway against its live /models list.
// Source: _tasks/features-v3.8.28/free-tier-research-2026-06-17.raw.json (50-agent web research + adversarial verification).
// HAND-CURATED free-tier catalog — there is no generator; edit the entries below directly.
// Provenance: seeded by the 2026-06-17 per-provider free-tier research pass (50-agent web research +
// adversarial verification); 2026-07-20 added the free tiers of providers we could already route but had
// never mapped (requesty, ovhcloud, agnes, glm), plus two new providers (navy, aihorde), and reconciled
// kilo-gateway against its live /models list; 2026-09-02 re-audited gemini, ollama-cloud, groq, nara and
// mistral against the providers' own pages.
// Evidence: every numeric block MUST carry an `// evidence:` comment naming its source class —
// public-page (a provider page anyone can read), api-public (an unauthenticated provider endpoint), or
// console-verified <date> por <who> (visible only inside an account console). No evidence ⇒ no number
// (the entry stays recurring-uncapped, monthlyTokens 0). Blocks that predate 2026-09-02 and still lack
// the comment inherit the 2026-06-17 research pass; add the comment whenever such a block is touched.
// Methodology: honest pool-deduped recurring tokens. "recurring-uncapped" = permanently free but no
// published token cap (rate/concurrency-limited) — NOT summed into the steady headline (see freeModelCatalog.ts).
// Deposit-unlock boosts (e.g. OpenRouter $10 -> 1000 RPD) live in FREE_TIER_BOOSTS, not per-record.
// Do not edit by hand — re-run the patch generator to refresh.
// Bump FREE_CATALOG_CURATED_AT on every change to the entries below.
import type { FreeModelBudget } from "./freeModelCatalog.ts";
/**
@@ -16,7 +22,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
* rewrites file timestamps on every deploy, which would report a months-old
* catalog as "updated today". Bump this whenever the entries below change.
*/
export const FREE_CATALOG_CURATED_AT = "2026-08-30";
export const FREE_CATALOG_CURATED_AT = "2026-09-03";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
@@ -106,9 +112,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "bytez", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "mistralai/Mistral-7B-Instruct-v0.3", displayName: "mistralai/Mistral-7B-Instruct-v0.3", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
// hardStopGuaranteed: Cerebras pricing page states "Free Trial: 1M tokens/day... no credit card" (open-sse/services/../providers/apikey/inference-hosts.ts:74-84).
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
// #11773: cerebras.ai/pricing (2026-09-03) is a one-time $5 signup credit
// gated on a payment method, 30-day expiry — not the old no-card 1M/day
// trial. creditTokens stays 0 because Cerebras publishes dollars, not a
// token grant. hardStopGuaranteed must stay unset: a stored card can bill.
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "cerebras", tos: "caution" },
// #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast.
{ provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
{ provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
@@ -173,20 +182,31 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "freemodel-dev", modelId: "gpt-5.3-codex", displayName: "GPT-5.3 Codex", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "freemodel-dev", tos: "unknown" },
{ provider: "friendliai", modelId: "meta-llama-3.1-70b-instruct", displayName: "meta-llama-3.1-70b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "friendliai", tos: "avoid" },
{ provider: "friendliai", modelId: "meta-llama-3.1-8b-instruct", displayName: "meta-llama-3.1-8b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "friendliai", tos: "avoid" },
{ provider: "gemini", modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", monthlyTokens: 60000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" },
{ provider: "gemini", modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" },
{ provider: "gemini", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash Preview", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" },
{ provider: "gemini", modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" },
// evidence: public-page https://ai.google.dev/gemini-api/docs/rate-limits (2026-08-18) — the per-model
// free-tier table was removed on 2025-12-23; the page now only says limits "can be viewed in Google AI
// Studio" and are "applied per project". No published token/RPD figure ⇒ recurring-uncapped (listed,
// never summed). Re-verify if Google republishes a table.
{ provider: "gemini", modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" },
{ provider: "gemini", modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" },
{ provider: "gemini", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash Preview", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" },
{ provider: "gemini", modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "gemini-free", tos: "caution" },
{ provider: "glm-cn", modelId: "glm-4-flash", displayName: "GLM-4-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "glm-cn", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "glm-cn", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "glm-cn", modelId: "glm-signup-bonus", displayName: "Z.AI — 20M signup bonus", monthlyTokens: 0, creditTokens: 20000000, freeType: "one-time-initial", poolKey: "zhipu-signup", tos: "ok" },
// hardStopGuaranteed: Groq pricing page states "Free tier: 30 RPM / 14.4K RPD — no credit card" (open-sse/services/../providers/apikey/frontier-labs.ts:71-81).
{ provider: "groq", modelId: "meta-llama/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
// evidence: public-page https://console.groq.com/docs/rate-limits (2026-09-02) — "Free Plan Limits":
// 200K TPD per model for the five chat models below; "Rate limits apply at the organization level".
// 200K × 30 = 6M per model; the cap is per model, so each row counts on its own (poolKey null).
// hardStopGuaranteed: same page — "When you exceed rate limits, our API returns a 429 Too Many Requests";
// https://console.groq.com/docs/billing-faqs — the Free tier has no payment method on file ("To upgrade
// from the Free tier to the Developer tier, you'll need to provide a valid payment method").
// Retired from the free tier (https://console.groq.com/docs/deprecations): llama-4-scout and qwen3-32b
// (2026-07-17), llama-3.3-70b-versatile (2026-08-16) — deliberately absent below.
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "openai/gpt-oss-safeguard-20b", displayName: "GPT-OSS Safeguard 20B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "qwen/qwen3.6-27b", displayName: "Qwen3.6 27B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "qwen/qwen3.8-27b", displayName: "Qwen3.8 27B", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: null, tos: "caution", hardStopGuaranteed: true },
{ provider: "huggingchat", modelId: "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT", displayName: "ERNIE 4.5 VL 424B A47B Base PT", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
{ provider: "huggingchat", modelId: "CohereLabs/c4ai-command-r7b-12-2024", displayName: "Command R7B 12-2024", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
{ provider: "huggingchat", modelId: "CohereLabs/command-a-reasoning-08-2025", displayName: "Command A Reasoning 08-2025", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
@@ -255,11 +275,26 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "llm7", modelId: "deepseek-r1-0528", displayName: "DeepSeek R1 (LLM7)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "llm7-free", tos: "caution" },
{ provider: "llm7", modelId: "qwen2.5-coder-32b-instruct", displayName: "Qwen2.5 Coder 32B (LLM7)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "llm7-free", tos: "caution" },
{ provider: "longcat", modelId: "LongCat-2.0", displayName: "LongCat-2.0", monthlyTokens: 0, creditTokens: 10000000, freeType: "one-time-initial", poolKey: "longcat-free", tos: "caution" },
// evidence: console-verified 2026-09-02 por diegosouzapw (https://console.mistral.ai → Limits, Free mode,
// "Tokens per month" = 1,000,000,000). Public pages only confirm that the cap exists:
// https://docs.mistral.ai/admin/billing-usage/usage-limits — "Free mode lets you create API keys and use
// included monthly usage within the limits shown on the Limits page";
// https://help.mistral.ai/en/articles/698531 — "Tokens per month: overall consumption cap", "set at the
// organization level". Re-verify in the console whenever this block is touched; without a dated
// console-verified line above, this pool MUST become recurring-uncapped (0).
{ provider: "mistral", modelId: "mistral-large-latest", displayName: "Mistral Large 3", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" },
{ provider: "mistral", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" },
{ provider: "mistral", modelId: "mistral-small-latest", displayName: "Mistral Small 4", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" },
{ provider: "mistral", modelId: "devstral-latest", displayName: "Devstral 2", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" },
{ provider: "mistral", modelId: "codestral-latest", displayName: "Codestral", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" },
// evidence: public-page https://modelscope.cn/docs/model-service/API-Inference/limits and
// https://modelscope.cn/docs/magicube/intro (2026-09-02) — API-Inference is free; calls are paid with
// 魔粒: "注册并登录 200 魔粒/日" + "绑定阿里云账号 50 魔粒/日", 1 魔粒 per call on "主流" models ⇒ ~250 calls/day
// ⇒ 250 × 800 × 30 = 6M/month, one balance per account (single pool).
// eligibilityGate: "账号注册后需绑定阿里云账号,并且通过实名认证后才可使用" (Alibaba Cloud binding + mainland
// real-name verification). The docs also call the product "非商业化,非盈利" — hence tos: caution.
{ provider: "modelscope", modelId: "Qwen/Qwen3.5-397B-A17B", displayName: "Qwen3.5 397B A17B (ModelScope)", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "modelscope-free", tos: "caution", eligibilityGate: "regional-identity" },
{ provider: "modelscope", modelId: "deepseek-ai/DeepSeek-V4-Pro", displayName: "DeepSeek V4 Pro (ModelScope)", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "modelscope-free", tos: "caution", eligibilityGate: "regional-identity" },
{ provider: "monsterapi", modelId: "llama-3-8b-fuse", displayName: "Llama 3 8B Fuse", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "monsterapi", tos: "ambiguous" },
{ provider: "morph", modelId: "morph-v3-large", displayName: "morph-v3-large", monthlyTokens: 400000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "morph", tos: "ok" },
{ provider: "morph", modelId: "morph-v3-fast", displayName: "morph-v3-fast", monthlyTokens: 400000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "morph", tos: "ok" },
@@ -280,14 +315,17 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
{ provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
{ provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
{ provider: "ollama-cloud", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "kimi-k2.6", displayName: "Kimi K2.6", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "gemma4:31b", displayName: "Gemma 4 31B", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "nemotron-3-super", displayName: "NVIDIA Nemotron 3 Super", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "qwen3.5:397b", displayName: "Qwen 3.5 397B", monthlyTokens: 20000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "ollama-cloud", tos: "ambiguous" },
// evidence: public-page https://ollama.com/pricing (2026-09-02) — Free plan: "Starter usage credits
// included · Includes access to starter models · Add credits to unlock all models"; docs.ollama.com/cloud:
// "usage resets monthly". No token figure and no named starter-model list ⇒ recurring-uncapped.
{ provider: "ollama-cloud", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "kimi-k2.6", displayName: "Kimi K2.6", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "gemma4:31b", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "nemotron-3-super", displayName: "NVIDIA Nemotron 3 Super", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "ollama-cloud", modelId: "qwen3.5:397b", displayName: "Qwen 3.5 397B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "ollama-cloud", tos: "ambiguous" },
{ provider: "opencode", modelId: "big-pickle", displayName: "Big Pickle", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "opencode", tos: "avoid" },
{ provider: "opencode", modelId: "deepseek-v4-flash-free", displayName: "DeepSeek V4 Flash Free", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "opencode", tos: "avoid" },
{ provider: "opencode", modelId: "minimax-m2.5-free", displayName: "MiniMax M2.5 Free", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "opencode", tos: "avoid" },
@@ -456,7 +494,16 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "routeway", modelId: "laguna-m.1:free", displayName: "Laguna M.1 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "routeway-free", tos: "caution" },
{ provider: "routeway", modelId: "laguna-xs.2:free", displayName: "Laguna XS.2 (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "routeway-free", tos: "caution" },
{ provider: "routeway", modelId: "llama-3.2-3b-instruct:free", displayName: "Llama 3.2 3B Instruct (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "routeway-free", tos: "caution" },
{ provider: "nara", modelId: "tencent-hy3", displayName: "Tencent Hy3", monthlyTokens: 150000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "mistral-large", displayName: "Mistral Large", monthlyTokens: 150000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 150000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
// evidence: api-public https://router.bynara.id/api/plans (2026-09-02) — plan "free": token_cap_daily=7000000,
// rpm_limit=15, models=[agnes-2.0-flash, agnes-2.5-flash, laguna-s-2.1, minimax-m3-free, mistral-large,
// mistral-medium-3-5, qwen3.8-27b, stepfun-3.7-flash]; home: "Token Cap 7M / day · Free tokens reset daily
// at 07:00 WIB". One daily bucket per account ⇒ single pool: 7M × 30 = 210M. Key requires linking Telegram.
{ provider: "nara", modelId: "agnes-2.0-flash", displayName: "Agnes 2.0 Flash", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "agnes-2.5-flash", displayName: "Agnes 2.5 Flash", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "laguna-s-2.1", displayName: "Laguna S 2.1", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "minimax-m3-free", displayName: "MiniMax M3 Free", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "mistral-large", displayName: "Mistral Large", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "qwen3.8-27b", displayName: "Qwen3.8 27B", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
{ provider: "nara", modelId: "stepfun-3.7-flash", displayName: "StepFun 3.7 Flash", monthlyTokens: 210000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "nara-free", tos: "caution" },
];

View File

@@ -11,6 +11,13 @@ export type FreeModelFreeType =
| "keyless"
| "discontinued";
/**
* A real, recurring quota that only opens after an identity check tied to a
* region (e.g. 实名认证 with a mainland-China ID). One member today; extend the
* union when a second kind of gate is catalogued.
*/
export type FreeEligibilityGate = "regional-identity";
export interface FreeModelBudget {
provider: string;
modelId: string;
@@ -40,14 +47,28 @@ export interface FreeModelBudget {
* `open-sse/services/autoCombo/strictZeroCostFilter.ts`.
*/
hardStopGuaranteed?: boolean;
/**
* Set when the quota is real and recurring but only reachable after a
* region-bound identity verification. Affects COUNTING only: the row
* leaves the steady headline and lands in `gatedRecurringTokens`.
* Routing, `isFreeModel` and STRICT_ZERO_COST read `freeType` alone.
* Put the gate's source in a comment next to the entry.
*/
eligibilityGate?: FreeEligibilityGate;
}
export interface FreeModelTotals {
/** Pool-deduped recurring tokens/month — the headline "steady" number. */
steadyRecurringTokens: number;
/** Steady + recurring credit grants (e.g. monthly $-credit plans). */
/**
* Steady + recurring credit grants (e.g. monthly $-credit plans).
* Eligibility-gated rows contribute nothing, exactly like the steady headline.
*/
steadyWithRecurringCreditsTokens: number;
/** Steady + recurring + one-time signup credits — first-month only. */
/**
* Steady + recurring + one-time signup credits — first-month only.
* Eligibility-gated rows contribute nothing, exactly like the steady headline.
*/
firstMonthRealisticTokens: number;
/**
* Extra recurring tokens/month unlocked by a one-time small deposit
@@ -59,8 +80,16 @@ export interface FreeModelTotals {
* Providers that are permanently free but publish NO token cap
* (rate/concurrency-limited). Real access, but un-quantifiable — listed,
* never summed into the headline (avoids the rate-limit×24/7 inflation).
* Eligibility-gated rows are excluded: the list reads as "open to anyone".
*/
uncappedProviders: string[];
/**
* Pool-deduped tokens/month behind an eligibility gate (same rule as the
* headline). Never summed into `steadyRecurringTokens`.
*/
gatedRecurringTokens: number;
/** Providers (sorted) contributing to `gatedRecurringTokens`. */
gatedProviders: string[];
modelCount: number;
poolCount: number;
perModel: FreeModelBudget[];
@@ -224,41 +253,60 @@ export function computeFreeModelTotals(
(m) => !(opts.excludeTosAvoid && m.tos === "avoid") && m.enabled !== false
);
const isGated = (m: FreeModelBudget) => m.eligibilityGate !== undefined;
const steadyRecurringTokens = dedupedSum(
models,
(m) => m.monthlyTokens,
(m) => STEADY_MONTHLY.has(m.freeType)
(m) => STEADY_MONTHLY.has(m.freeType) && !isGated(m)
);
const gatedRecurringTokens = dedupedSum(
models,
(m) => m.monthlyTokens,
(m) => STEADY_MONTHLY.has(m.freeType) && isGated(m)
);
const gatedProviders = [
...new Set(
models.filter((m) => STEADY_MONTHLY.has(m.freeType) && isGated(m)).map((m) => m.provider)
),
].sort();
const recurringCredits = dedupedSum(
models,
(m) => m.creditTokens,
(m) => RECURRING_CREDIT.has(m.freeType)
(m) => RECURRING_CREDIT.has(m.freeType) && !isGated(m)
);
const oneTimeCredits = dedupedSum(
models,
(m) => m.creditTokens,
(m) => ONE_TIME_CREDIT.has(m.freeType)
(m) => ONE_TIME_CREDIT.has(m.freeType) && !isGated(m)
);
const steadyWithRecurringCreditsTokens = steadyRecurringTokens + recurringCredits;
const firstMonthRealisticTokens = steadyWithRecurringCreditsTokens + oneTimeCredits;
const poolCount = new Set(
models.filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
models
.filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey && !isGated(m))
.map((m) => m.poolKey)
).size;
// Deposit-unlock boost: sum the FREE_TIER_BOOSTS whose pool still has a live
// recurring model in the (optionally ToS-filtered) set.
const livePools = new Set(
models.filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
models
.filter((m) => STEADY_MONTHLY.has(m.freeType) && m.poolKey && !isGated(m))
.map((m) => m.poolKey)
);
const boostMonthlyTokens = Object.entries(FREE_TIER_BOOSTS)
.filter(([pool]) => livePools.has(pool))
.reduce((s, [, b]) => s + b.boostMonthlyTokens, 0);
// Permanently-free-but-uncapped providers (real access, no published cap).
// Gated rows are excluded: the list is read as "anyone can use this, forever".
const uncappedProviders = [
...new Set(models.filter((m) => UNCAPPED.has(m.freeType)).map((m) => m.provider)),
...new Set(
models.filter((m) => UNCAPPED.has(m.freeType) && !isGated(m)).map((m) => m.provider)
),
].sort();
return {
@@ -267,6 +315,8 @@ export function computeFreeModelTotals(
firstMonthRealisticTokens,
boostMonthlyTokens,
uncappedProviders,
gatedRecurringTokens,
gatedProviders,
modelCount: models.length,
poolCount,
perModel: models.slice().sort((a, b) => b.monthlyTokens - a.monthlyTokens),

View File

@@ -6,20 +6,20 @@
* (explicit daily/monthly token cap, or documented RPD × ~800 tokens × 30).
*
* Deliberately EXCLUDED (rate-limit-only, no published token cap — theoretical,
* not granted): tencent, siliconflow, nvidia, baidu, publicai, sparkdesk.
* not granted): tencent, siliconflow, nvidia, baidu, publicai, sparkdesk,
* gemini (no per-model limits published since 2025-12), ollama-cloud (starter
* credits, no figure).
* One-time signup credits and discontinued tiers are excluded (do not recur).
*/
export type TosVerdict = "ok" | "caution" | "ambiguous" | "avoid" | "unknown";
export const FREE_TIER_BUDGETS: Record<string, number> = {
mistral: 1_000_000_000,
nara: 210_000_000,
"cloudflare-ai": 122_000_000,
gemini: 60_000_000,
doubao: 60_000_000,
cerebras: 30_000_000,
groq: 30_000_000,
"api-airforce": 24_000_000,
"ollama-cloud": 20_000_000,
groq: 15_000_000,
bluesminds: 7_200_000,
sambanova: 6_000_000,
"arcee-ai": 4_800_000,

View File

@@ -255,6 +255,7 @@ export const GLMT_REQUEST_DEFAULTS = Object.freeze({
});
export const GLM_COUNT_TOKENS_TIMEOUT_MS = 3_000;
/** Module-load snapshot. Wire UA uses getClaudeCodeUserAgent("sdk-cli"). */
export const GLM_CLAUDE_CODE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli");
export const GLM_ANTHROPIC_BETA = [
"claude-code-20250219",
@@ -582,7 +583,7 @@ export function buildGlmBaseHeaders(apiKey: string, stream = true): Record<strin
"anthropic-version": ANTHROPIC_VERSION_HEADER,
"anthropic-beta": GLM_ANTHROPIC_BETA,
"anthropic-dangerous-direct-browser-access": "true",
"User-Agent": GLM_CLAUDE_CODE_USER_AGENT,
"User-Agent": getClaudeCodeUserAgent("sdk-cli"),
"X-Stainless-Lang": "js",
"X-Stainless-Runtime": "node",
"X-Stainless-Retry-Count": "0",

View File

@@ -9,12 +9,38 @@ import type { AntigravityClientProfile } from "@/shared/constants/antigravityCli
// returns a narrower list. Version strings track the live-captured CLI 1.0.81-6.
export const GITHUB_COPILOT_API_VERSION = "2026-08-01";
export const GITHUB_COPILOT_CLI_VERSION = "1.0.81-6";
const GITHUB_COPILOT_VERSION_OVERRIDE_ENV = "GITHUB_COPILOT_CLI_VERSION";
const SAFE_COPILOT_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
function getSafeCopilotEnvValue(name: string, pattern: RegExp): string | null {
const raw = typeof process === "undefined" ? undefined : process.env?.[name];
if (typeof raw !== "string") return null;
const normalized = raw.trim();
if (!normalized || !pattern.test(normalized)) {
return null;
}
return normalized;
}
/** Captured pin, overridable via GITHUB_COPILOT_CLI_VERSION (#12417). */
export function getGitHubCopilotCliVersion(): string {
return (
getSafeCopilotEnvValue(GITHUB_COPILOT_VERSION_OVERRIDE_ENV, SAFE_COPILOT_VERSION_PATTERN) ||
GITHUB_COPILOT_CLI_VERSION
);
}
export const GITHUB_COPILOT_EDITOR_VERSION = `copilot/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = `copilot-chat/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_CHAT_USER_AGENT = `GitHubCopilotChat/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_CLI_USER_AGENT = `copilot/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_REFRESH_PLUGIN_VERSION = `copilot/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_REFRESH_USER_AGENT = "GithubCopilot/1.0";
/** Request-time Copilot Chat UA. Pin consts above stay for lockstep tests (#12417). */
export function getGitHubCopilotChatUserAgent(): string {
return `GitHubCopilotChat/${getGitHubCopilotCliVersion()}`;
}
export const GITHUB_COPILOT_INTEGRATION_ID = "copilot-developer-cli";
export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-agent";
export const GITHUB_COPILOT_INTERACTION_TYPE = "conversation-user";
@@ -62,10 +88,11 @@ export function getGitHubCopilotChatHeaders(
// send exactly the CLI's set. The `copilot-integration-id` (copilot-developer-cli)
// is the catalog-unlock lever; the stable X-Client-Machine-Id is the CLI's
// per-install device fingerprint.
const version = getGitHubCopilotCliVersion();
const headers: Record<string, string> = {
"copilot-integration-id": GITHUB_COPILOT_INTEGRATION_ID,
"editor-version": GITHUB_COPILOT_EDITOR_VERSION,
"user-agent": GITHUB_COPILOT_CLI_USER_AGENT,
"editor-version": `copilot/${version}`,
"user-agent": `copilot/${version}`,
"openai-intent": options.intent || GITHUB_COPILOT_OPENAI_INTENT,
"x-interaction-type": GITHUB_COPILOT_INTERACTION_TYPE,
"copilot-harness-id": GITHUB_COPILOT_HARNESS_ID,
@@ -128,23 +155,25 @@ export function getQwenCliUserAgent(version = QWEN_CLI_VERSION): string {
}
export function getGitHubCopilotInternalUserHeaders(authorization: string): Record<string, string> {
const version = getGitHubCopilotCliVersion();
return {
Authorization: authorization,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_COPILOT_API_VERSION,
"User-Agent": GITHUB_COPILOT_CHAT_USER_AGENT,
"Editor-Version": GITHUB_COPILOT_EDITOR_VERSION,
"Editor-Plugin-Version": GITHUB_COPILOT_CHAT_PLUGIN_VERSION,
"User-Agent": `GitHubCopilotChat/${version}`,
"Editor-Version": `copilot/${version}`,
"Editor-Plugin-Version": `copilot-chat/${version}`,
};
}
export function getGitHubCopilotRefreshHeaders(authorization: string): Record<string, string> {
const version = getGitHubCopilotCliVersion();
return {
Authorization: authorization,
Accept: "application/json",
"User-Agent": GITHUB_COPILOT_REFRESH_USER_AGENT,
"Editor-Version": GITHUB_COPILOT_EDITOR_VERSION,
"Editor-Plugin-Version": GITHUB_COPILOT_REFRESH_PLUGIN_VERSION,
"Editor-Version": `copilot/${version}`,
"Editor-Plugin-Version": `copilot/${version}`,
};
}

View File

@@ -7,7 +7,6 @@ import {
ANTHROPIC_VERSION_HEADER,
CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
CLAUDE_CLI_STAINLESS_RUNTIME_VERSION,
CLAUDE_CLI_USER_AGENT,
resolvePublicCred,
} from "../../shared.ts";

View File

@@ -24,6 +24,7 @@ export const groqProvider: RegistryEntry = {
{ id: "openai/gpt-oss-20b", name: "GPT-OSS 20B" },
{ id: "qwen/qwen3-32b", name: "Qwen3 32B" },
{ id: "qwen/qwen3.6-27b", name: "Qwen3.6 27B" },
{ id: "qwen/qwen3.8-27b", name: "Qwen3.8 27B" },
{ id: "openai/gpt-oss-safeguard-20b", name: "GPT-OSS Safeguard 20B" },
],
};

View File

@@ -4,16 +4,60 @@ import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
/**
* NaraRouter — OpenAI-compatible aggregator (router.bynara.id).
*
* Free key issued via their Telegram channel. The free tier is a shared
* 5M-tokens/day pool; many models are gated behind
* credit/plan, so only the free-tier models are pinned.
* Free key issued after linking a Telegram account. The free plan is one
* 7M-tokens/day bucket per account (GET /api/plans, 2026-09-02); only the
* plan's own models are pinned. Context lengths mirror the same models in
* our own registry (agnes, poolside, novita, stepfun); qwen3.8-27b has no
* published context yet, so it carries none.
*/
export const naraProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "nara",
baseUrl: "https://router.bynara.id/v1/chat/completions",
models: [
{ id: "tencent-hy3", name: "Tencent Hy3", contextLength: 1000000 },
{
id: "agnes-2.0-flash",
name: "Agnes 2.0 Flash",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "agnes-2.5-flash",
name: "Agnes 2.5 Flash",
contextLength: 524288,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "laguna-s-2.1",
name: "Laguna S 2.1",
contextLength: 262144,
toolCalling: true,
supportsReasoning: true,
},
{
id: "minimax-m3-free",
name: "MiniMax M3 (free)",
contextLength: 1000000,
supportsVision: true,
supportsReasoning: true,
},
{ id: "mistral-large", name: "Mistral Large", contextLength: 252000, toolCalling: true },
{ id: "mistral-medium-3-5", name: "Mistral Medium 3.5", contextLength: 256000, toolCalling: true, supportsVision: true },
{
id: "mistral-medium-3-5",
name: "Mistral Medium 3.5",
contextLength: 256000,
toolCalling: true,
supportsVision: true,
},
{ id: "qwen3.8-27b", name: "Qwen3.8 27B", toolCalling: true },
{
id: "stepfun-3.7-flash",
name: "StepFun 3.7 Flash",
contextLength: 262144,
toolCalling: true,
},
],
});

View File

@@ -16,6 +16,7 @@ import {
CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
CLAUDE_CLI_STAINLESS_RUNTIME_VERSION,
CLAUDE_CLI_USER_AGENT,
getClaudeCodeUserAgent,
} from "../anthropicHeaders.ts";
import { getCodexDefaultHeaders } from "../codexClient.ts";
import {
@@ -761,7 +762,7 @@ export function getClaudeCliHeaders(): Record<string, string> {
"Anthropic-Version": ANTHROPIC_VERSION_HEADER,
"Anthropic-Beta": ANTHROPIC_BETA_CLAUDE_OAUTH,
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": CLAUDE_CLI_USER_AGENT,
"User-Agent": getClaudeCodeUserAgent("cli"),
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",

View File

@@ -6,8 +6,8 @@ import {
type AlternateFormat,
} from "../config/providers/alternateFormats.ts";
import {
CLAUDE_CLI_BILLING_VERSION,
CLAUDE_CLI_STAINLESS_RUNTIME_VERSION,
getClaudeCliBillingVersion,
mergeClientAnthropicBeta,
normalizeAnthropicHeaderVariants,
} from "../config/anthropicHeaders.ts";
@@ -86,7 +86,7 @@ import {
} from "../services/contextManager.ts";
import { randomUUID } from "node:crypto";
import {
CLAUDE_CODE_VERSION,
getClaudeCodeVersion,
CLAUDE_CODE_STAINLESS_VERSION,
buildUserIdJson,
getSessionId,
@@ -1163,7 +1163,7 @@ export class BaseExecutor {
// system[0] (billing) and system[1] (sentinel) must not carry
// cache_control — that belongs on upstream prompt blocks at [2..].
const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_BILLING_VERSION}; cc_entrypoint=cli; cch=00000;`;
const billingLine = `x-anthropic-billing-header: cc_version=${getClaudeCliBillingVersion()}; cc_entrypoint=cli; cch=00000;`;
const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude.";
const sysBlocks: Array<Record<string, unknown>> = Array.isArray(tb.system)
@@ -1259,7 +1259,7 @@ export class BaseExecutor {
),
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
"User-Agent": `claude-cli/${getClaudeCodeVersion()} (external, cli)`,
"X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION,
"X-Stainless-Timeout": "600",
"accept-encoding": "gzip, deflate, br, zstd",

View File

@@ -13,11 +13,15 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import {
CLAUDE_CODE_CLIENT_VERSION,
CLAUDE_CODE_SDK_PACKAGE_VERSION,
getClaudeCodeClientVersion,
} from "@/shared/constants/claudeCodeClient";
// ---------- Versions ------------------------------------------------------
export const CLAUDE_CODE_VERSION = CLAUDE_CODE_CLIENT_VERSION;
export function getClaudeCodeVersion(): string {
return getClaudeCodeClientVersion();
}
/** Bundled @anthropic-ai/sdk version for the pinned CLI release. */
export const CLAUDE_CODE_STAINLESS_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION;
@@ -156,7 +160,7 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise<ClaudeB
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
"User-Agent": `claude-cli/${getClaudeCodeVersion()} (external, cli)`,
"anthropic-beta": "oauth-2025-04-20",
},
signal: ctrl.signal,

View File

@@ -60,10 +60,10 @@ describe("TierResolver", () => {
expect(result.hasFreeTier).toBe(true);
});
it("classifies Cerebras as free", () => {
it("classifies Cerebras as not free after the no-card trial ended (#11773)", () => {
const result = classifyTier("cerebras", "llama-3.1-70b");
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
expect(result.tier).not.toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(false);
});
it("classifies Groq as free", () => {
@@ -228,7 +228,6 @@ describe("TierResolver", () => {
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq",
]) {
expect(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`).toBe(

View File

@@ -55,6 +55,14 @@ export function getQuotaScopeLabelForProvider(
return getAntigravityQuotaFamily(model) === "other" ? "model" : "family";
}
export function getQuotaFetchScope(
provider: string | null | undefined,
model: string | null | undefined
): string {
if (provider !== "antigravity" && provider !== "agy") return "*";
return getQuotaScopedModelForProvider(provider, model) ?? "*";
}
export function isAntigravityQuotaProvider(provider: string | null | undefined): boolean {
return provider === "antigravity" || provider === "agy";
}

View File

@@ -24,6 +24,7 @@ import { createHash } from "node:crypto";
import {
CLAUDE_CODE_CLIENT_BUILD_REVISION,
CLAUDE_CODE_CLIENT_VERSION,
getClaudeCodeClientVersion,
} from "@/shared/constants/claudeCodeClient";
// ────────────────────────────────────────────────────────────────────────────
@@ -122,6 +123,9 @@ export const CCH_SALT = "59cf53e54c78";
export const CCH_POSITIONS = [4, 7, 20] as const;
/** Default `cc_version=` value embedded in the billing header. */
export const DEFAULT_CLAUDE_CODE_VERSION = CLAUDE_CODE_CLIENT_VERSION;
export function getDefaultClaudeCodeVersion(): string {
return getClaudeCodeClientVersion();
}
/** Identity sentinel prepended for Claude Agent SDK callers. */
export const CLAUDE_AGENT_SDK_IDENTITY =
"You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -292,7 +296,7 @@ export function buildBillingHeaderValue(
messages: Message[],
options: BuildBillingHeaderOptions
): string {
const version = options.version || DEFAULT_CLAUDE_CODE_VERSION;
const version = options.version || getDefaultClaudeCodeVersion();
const firstUserText = extractFirstUserMessageText(messages);
const suffix =

View File

@@ -5,7 +5,7 @@ import { ANTHROPIC_VERSION_HEADER } from "../config/anthropicHeaders.ts";
import {
CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION,
CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION,
CLAUDE_CODE_COMPATIBLE_USER_AGENT,
getClaudeCodeUserAgent,
} from "../config/claudeCodeCompatibleIdentity.ts";
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
@@ -183,7 +183,7 @@ export function buildClaudeCodeCompatibleHeaders(
}),
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT,
"User-Agent": getClaudeCodeUserAgent("sdk-cli"),
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS),
"X-Stainless-Lang": "js",

View File

@@ -69,6 +69,7 @@ import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLocko
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
import { resolveProviderId } from "../../src/shared/constants/providers.ts";
import { getQuotaFetchScope } from "./antigravityQuotaFamily.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
import { parseModel } from "./model.ts";
@@ -178,6 +179,7 @@ import {
} from "./combo/validateQuality.ts";
import {
resolveComboCooldownWaitDecision,
resolveCircuitOpenWaitDecision,
ResolveComboCooldownDecisionResult,
} from "./combo/comboCooldownRetry.ts";
import {
@@ -595,14 +597,17 @@ export async function buildAutoCandidates(
statusPenaltyReason = connectionStatusReason;
}
if (fetcher && target.connectionId) {
const quotaKey = `${provider}:${target.connectionId}`;
const quotaScope = getQuotaFetchScope(provider, target.modelStr);
const quotaKey = `${provider}:${target.connectionId}:${quotaScope}`;
if (!quotaPromises.has(quotaKey)) {
quotaPromises.set(
quotaKey,
fetchResetAwareQuotaWithCache({
provider,
connectionId: target.connectionId,
connection,
connection: connection
? { ...connection, requestedModel: target.modelStr }
: connection,
fetcher,
config: resetWindowConfig,
log: {},
@@ -1133,6 +1138,8 @@ async function handleComboChatInner({
let lastError: string | null = null;
let earliestRetryAfter: ComboRetryAfter | null = null;
let lastStatus: number | null = null;
let skippedForCircuitOpen = false;
let earliestCircuitOpenRetryMs = 0;
// #11804: the loop-safety timer is armed per setTry iteration but must be
// cleared on EVERY exit path, not just the happy one. Hoisted to function
// scope so the `finally` at the end of this function always reaches it —
@@ -1151,6 +1158,8 @@ async function handleComboChatInner({
const exhaustedProviders = new Set<string>();
const exhaustedConnections = new Set<string>();
const transientRateLimitedProviders = new Set<string>();
skippedForCircuitOpen = false;
earliestCircuitOpenRetryMs = 0;
if (setTry > 0) {
log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`);
await new Promise((resolve) => {
@@ -1272,7 +1281,15 @@ async function handleComboChatInner({
};
const cb = getCircuitBreaker(provider);
if (cb.getStatus().state === "OPEN") {
const cbStatus = cb.getStatus();
if (cbStatus.state === "OPEN") {
skippedForCircuitOpen = true;
if (
cbStatus.retryAfterMs > 0 &&
(earliestCircuitOpenRetryMs === 0 || cbStatus.retryAfterMs < earliestCircuitOpenRetryMs)
) {
earliestCircuitOpenRetryMs = cbStatus.retryAfterMs;
}
log.info("COMBO", `Skipping ${modelStr} — circuit breaker OPEN for ${provider}`);
recordComboDecision(traceInvocationId, {
step: target.executionKey,
@@ -1380,7 +1397,8 @@ async function handleComboChatInner({
resilienceSettings,
quotaCutoffResetWindowConfig,
combo.name,
log, modelStr
log,
modelStr
);
if (quotaCutoff.blocked) {
log.info(
@@ -2762,6 +2780,32 @@ async function handleComboChatInner({
// Retry the entire set if more attempts remain
if (setTry < maxSetRetries) continue;
if (!lastStatus && recordedAttempts === 0 && comboCooldownWaitEnabled) {
const circuitOpenWait = resolveCircuitOpenWaitDecision({
skippedForCircuitOpen,
retryAfterMs: earliestCircuitOpenRetryMs,
attempt: comboCooldownAttempt,
budgetLeftMs: comboCooldownBudgetLeftMs,
settings: resilienceSettings.comboCooldownWait,
});
if (circuitOpenWait.wait) {
log.info(
"COMBO",
`${strategy} circuit-open wait: waiting ${Math.ceil(circuitOpenWait.waitMs / 1000)}s (reason=${circuitOpenWait.reason ?? "circuit_open"}) then retrying (attempt ${comboCooldownAttempt + 1}/${resilienceSettings.comboCooldownWait.maxAttempts})`
);
const completed = await waitForCooldownAwareRetry(circuitOpenWait.waitMs, signal);
if (!completed) {
return errorResponse(499, "Request aborted");
}
comboCooldownAttempt += 1;
comboCooldownBudgetLeftMs = Math.max(
0,
comboCooldownBudgetLeftMs - circuitOpenWait.waitMs
);
return dispatchWithCooldownRetry();
}
}
// All set retries exhausted — return the final error
// #10681: finalize the decision trace (all targets failed or skipped).
finalizeComboTrace(traceInvocationId, orderedTargets);

View File

@@ -56,6 +56,7 @@ export const COMBO_COOLDOWN_RETRYABLE_REASONS: ReadonlySet<string> = new Set([
"transient",
"overloaded",
"server_error",
"circuit_open",
]);
export interface ComboCooldownWaitSettings {
@@ -256,3 +257,36 @@ export function resolveComboCooldownWaitDecision(
reason: typeof best.reason === "string" ? best.reason : null,
};
}
export interface ResolveCircuitOpenWaitInput {
skippedForCircuitOpen: unknown;
retryAfterMs: unknown;
attempt: number;
budgetLeftMs: number;
settings: ComboCooldownWaitSettings;
}
/**
* When every combo target was pre-skipped because the whole-provider breaker is
* OPEN, wait out a SHORT reset instead of crystallizing ALL_TARGETS_SKIPPED.
* Same ceilings as model-lockout waits. Live incident 2026-09-03: offical-fable
* (single claude target) returned 43ms 503 while the breaker reset was 60s.
*/
export function resolveCircuitOpenWaitDecision(
input: ResolveCircuitOpenWaitInput
): ResolveComboCooldownDecisionResult {
if (input.settings.enabled !== true || input.skippedForCircuitOpen !== true) {
return { wait: false, waitMs: 0, reason: null };
}
const retryAfterMs = toFiniteWaitMs(input.retryAfterMs);
if (retryAfterMs <= 0) return { wait: false, waitMs: 0, reason: null };
const waitMs = retryAfterMs + COMBO_COOLDOWN_WAIT_MARGIN_MS;
const decision = shouldWaitForComboCooldown({
reason: "circuit_open",
waitMs,
attempt: input.attempt,
budgetLeftMs: input.budgetLeftMs,
settings: input.settings,
});
return { ...decision, reason: "circuit_open" };
}

View File

@@ -11,7 +11,11 @@ import { remainingPercentFromQuotaWindows } from "../antigravityQuotaFamily.ts";
import { errorResponse } from "../../utils/error.ts";
import { parseModel } from "../model.ts";
import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts";
import { isLocalStreamLifecycleError, isLocalExecutionError } from "@/shared/utils/circuitBreaker";
import {
isLocalStreamLifecycleError,
isLocalExecutionError,
isModelCapacityOverloadError,
} from "@/shared/utils/circuitBreaker";
import { CONTEXT_OVERFLOW_PATTERNS, MODEL_ACCESS_DENIED_PATTERNS } from "../accountFallback.ts";
import { isResourceNotFoundResponse } from "../errorClassifier.ts";
import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts";
@@ -213,6 +217,12 @@ export function shouldRecordProviderBreakerFailure(args: {
}): boolean {
return (
(!args.isStreamReadinessFailure || args.isStreamEarlyEof === true) &&
// Overloaded 502 (STREAM_EARLY_EOF wrapping "Overloaded") must not trip
// the whole-provider breaker. The status=529 check is defense in depth:
// 529 is not in PROVIDER_BREAKER_FAILURE_STATUSES today, but a later
// addition of 529 to that set must still stay off the breaker.
!isModelCapacityOverloadError(args.error) &&
!isModelCapacityOverloadError(args.status) &&
PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) &&
(!args.sameProviderNext || args.isProxyUnreachable === true) &&
!args.skipProviderBreaker &&
@@ -441,10 +451,7 @@ export function quotaRemainingPercentFromQuota(
const windows = record.windows;
if (windows && typeof windows === "object" && !Array.isArray(windows)) {
const fromWindows = remainingPercentFromQuotaWindows(
windows as Record<string, unknown>,
scope
);
const fromWindows = remainingPercentFromQuotaWindows(windows as Record<string, unknown>, scope);
if (fromWindows !== null) return fromWindows;
}

View File

@@ -119,7 +119,7 @@ export async function resolveQuotaExhaustionCutoffForTarget(
const quota = await fetchResetAwareQuotaWithCache({
provider,
connectionId,
connection,
connection: connection ? { ...connection, requestedModel } : connection,
fetcher,
config: resetWindowConfig,
log,

View File

@@ -46,6 +46,7 @@ import {
} from "./quotaScoring.ts";
import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts";
import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersist.ts";
import { getQuotaFetchScope } from "../antigravityQuotaFamily.ts";
import { isQuotaExhaustedForRequest } from "../../../src/domain/quotaCache.ts";
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
@@ -269,14 +270,17 @@ async function scoreQuotaAwareTargets<TScore extends object>({
const provider = getResetAwareProvider(target);
const fetcher = provider ? getQuotaFetcher(provider) : null;
if (fetcher && provider && target.connectionId) {
const quotaKey = `${provider}:${target.connectionId}`;
const quotaKey = `${provider}:${target.connectionId}:${getQuotaFetchScope(provider, target.modelStr)}`;
if (!quotaPromises.has(quotaKey)) {
const connection = connectionById.get(target.connectionId);
quotaPromises.set(
quotaKey,
fetchResetAwareQuotaWithCache({
provider,
connectionId: target.connectionId,
connection: connectionById.get(target.connectionId),
connection: connection
? { ...connection, requestedModel: target.modelStr }
: connection,
fetcher,
config,
log,
@@ -354,7 +358,10 @@ export async function fetchResetAwareQuotaWithCache({
log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
comboName: string;
}): Promise<unknown> {
const cacheKey = `${provider}:${connectionId}`;
const requestedModel =
typeof connection?.requestedModel === "string" ? connection.requestedModel : null;
const cacheScope = getQuotaFetchScope(provider, requestedModel);
const cacheKey = `${provider}:${connectionId}:${cacheScope}`;
const ttlMs = config.quotaCacheTtlMs;
const maxStaleMs = config.quotaCacheMaxStaleMs;
const now = Date.now();

View File

@@ -56,6 +56,25 @@ function isEmptyContentFailure(status: number, errorText: string): boolean {
return status === 502 && (/empty content/i.test(errorText) || /empty response/i.test(errorText));
}
/** #12441 — quota/credits bodies must not take the 401/403 auth-skip path. */
export function isQuotaOrCreditsError(
errorText: string,
structuredError?: { code?: string; type?: string; message?: string }
): boolean {
const blobs = [
errorText,
structuredError?.type,
structuredError?.message,
structuredError?.code,
].filter((value): value is string => Boolean(value));
const joined = blobs.join(" ");
if (/credits exhausted/i.test(joined)) return true;
if (/quota exhausted/i.test(joined) && !/authentication expired/i.test(joined)) return true;
// Classify each candidate independently. A non-quota structuredError.code must
// not hide quota wording in errorText or structuredError.message.
return blobs.some((blob) => classifyErrorText(blob) === RateLimitReason.QUOTA_EXHAUSTED);
}
export type ComboExhaustionSets = {
exhaustedProviders: Set<string>;
exhaustedConnections: Set<string>;
@@ -173,12 +192,14 @@ export function applyComboTargetExhaustion(
.filter(Boolean)
.join(" ")
);
const quotaMisclassifiedAsAuth = isQuotaOrCreditsError(errorText, structuredError);
if (
AUTH_LEVEL_ERROR_STATUSES.includes(result.status) &&
// Cloudflare 1010 is a 403-ONLY fingerprint rejection. A 401 that merely happens to
// mention "1010" or "fingerprint_rejection" in a port/count/model token must NOT skip
// auth-level exhaustion — only a 403 carrying the Cloudflare fingerprint signal does.
!(result.status === 403 && (fingerprintToken || fingerprintText)) &&
!quotaMisclassifiedAsAuth &&
provider &&
provider !== "unknown"
) {

View File

@@ -256,7 +256,7 @@ export function classifyProviderError(
const oauthInvalid = isOAuthInvalidToken(bodyStr);
const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider);
if ((creditsExhausted || subscriptionQuotaExhausted) && [400, 402, 403].includes(statusCode)) {
if ((creditsExhausted || subscriptionQuotaExhausted) && [400, 401, 402, 403].includes(statusCode)) {
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
}

View File

@@ -24,6 +24,10 @@ import {
type QuotaFetcher,
type QuotaInfo,
} from "./quotaPreflight.ts";
import {
getAntigravityQuotaFamily,
getQuotaFetchScope,
} from "./antigravityQuotaFamily.ts";
type UsageFetcher = (
connection: Parameters<typeof getUsageForProvider>[0],
@@ -54,7 +58,7 @@ export function __agePendingForceRefreshForTests(
connectionId: string,
ageMs: number
): void {
pendingForceRefresh.set(cacheKey(provider, connectionId), Date.now() - ageMs);
pendingForceRefresh.set(connectionKey(provider, connectionId), Date.now() - ageMs);
}
/** Test-only: backdate a convert-null miss so the 60s hammer-guard is unit-testable. */
@@ -63,7 +67,7 @@ export function __agePendingForceRefreshMissForTests(
connectionId: string,
ageMs: number
): void {
pendingForceRefreshMiss.set(cacheKey(provider, connectionId), Date.now() - ageMs);
pendingForceRefreshMiss.set(connectionKey(provider, connectionId), Date.now() - ageMs);
}
/** Test-only: drop all wrapper/flag maps so tests cannot leak across ids. */
@@ -80,10 +84,25 @@ interface CacheEntry {
const cache = new Map<string, CacheEntry>();
function cacheKey(provider: string, connectionId: string): string {
function connectionKey(provider: string, connectionId: string): string {
return `${provider.trim()}::${connectionId.trim()}`;
}
function quotaCacheScope(
provider: string,
requestedModel?: string | null
): string {
return getQuotaFetchScope(provider, requestedModel);
}
function cacheKey(
provider: string,
connectionId: string,
requestedModel?: string | null
): string {
return `${connectionKey(provider, connectionId)}::${quotaCacheScope(provider, requestedModel)}`;
}
function dropExpiredPendingForceRefresh(key: string, now: number): boolean {
const stampedAt = pendingForceRefresh.get(key);
if (stampedAt === undefined) return true;
@@ -216,7 +235,15 @@ interface ConnectionInputs {
* / shape-unknown / missing). Exported for unit testing — the production path
* is `fetchGenericQuota`, which adds caching + the upstream call.
*/
export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null {
type UsageToQuotaContext = {
requestedModel?: string | null;
provider?: string | null;
};
export function convertUsageToQuotaInfo(
usage: unknown,
context: UsageToQuotaContext = {}
): QuotaInfo | null {
if (!usage || typeof usage !== "object") return null;
const usageRecord = usage as Record<string, unknown>;
if (
@@ -235,31 +262,51 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null {
}
const windows: Record<string, { percentUsed: number; resetAt: string | null }> = {};
let worstPercent = 0;
let worstResetAt: string | null = null;
for (const [name, entry] of Object.entries(quotasObj as Record<string, unknown>)) {
const percentUsed = percentUsedForQuota(entry);
if (percentUsed === null) continue;
const resetAt = resetAtForQuota(entry);
windows[name] = { percentUsed, resetAt };
if (percentUsed > worstPercent) {
worstPercent = percentUsed;
worstResetAt = resetAt;
}
windows[name] = { percentUsed, resetAt: resetAtForQuota(entry) };
}
if (Object.keys(windows).length === 0) return null;
const normalized = normalizeQuotaWindows(windows);
const requestedFamily =
isAntigravityProvider(context.provider) && context.requestedModel
? getAntigravityQuotaFamily(context.requestedModel)
: null;
const providerScopedWindows =
requestedFamily === "gemini" || requestedFamily === "claude"
? Object.fromEntries(
Object.entries(windows).filter(([key]) => {
if (key.endsWith("_weekly")) {
return antigravityWeeklyWindowMatchesFamily(key, requestedFamily);
}
return getAntigravityQuotaFamily(key) === requestedFamily;
})
)
: windows;
if (Object.keys(providerScopedWindows).length === 0) return null;
const normalized = normalizeQuotaWindows(providerScopedWindows, context);
const scopedEntries = Object.values(providerScopedWindows);
const percentUsed = scopedEntries.reduce(
(worst, entry) => Math.max(worst, entry.percentUsed),
0
);
const resetAt =
scopedEntries.reduce<{ percentUsed: number; resetAt: string | null } | null>(
(worst, entry) => (!worst || entry.percentUsed > worst.percentUsed ? entry : worst),
null
)?.resetAt ?? null;
return {
used: 0,
total: 0,
percentUsed: worstPercent,
resetAt: worstResetAt,
windows,
percentUsed,
resetAt,
windows: providerScopedWindows,
...normalized,
limitReached: worstPercent >= 1 - 1e-9,
limitReached: percentUsed >= 1 - 1e-9,
};
}
@@ -269,12 +316,29 @@ export function convertUsageToQuotaInfo(usage: unknown): QuotaInfo | null {
* naming convention.
*
* - Claude: "session (5h)" → window5h, "weekly (7d)" → window7d
* - Antigravity: worst per-model quota → window5h; worst *_weekly quota → window7d
* - Antigravity: requested-family model quota → window5h; matching family weekly quota → window7d
*/
function isAntigravityProvider(provider: string | null | undefined): boolean {
return provider === "antigravity" || provider === "agy";
}
function antigravityWeeklyWindowMatchesFamily(
key: string,
family: "gemini" | "claude"
): boolean {
if (!key.endsWith("_weekly")) return false;
return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly";
}
function normalizeQuotaWindows(
windows: Record<string, { percentUsed: number; resetAt: string | null }>
windows: Record<string, { percentUsed: number; resetAt: string | null }>,
context: UsageToQuotaContext
): Record<string, { percentUsed: number; resetAt: string | null }> {
const normalized: Record<string, { percentUsed: number; resetAt: string | null }> = {};
const requestedFamily =
isAntigravityProvider(context.provider) && context.requestedModel
? getAntigravityQuotaFamily(context.requestedModel)
: null;
// Claude-style explicit time windows.
if (windows["session (5h)"] && !normalized.window5h) {
@@ -284,22 +348,31 @@ function normalizeQuotaWindows(
normalized.window7d = windows["weekly (7d)"];
}
// Antigravity-style per-model 5h windows: pick the worst (most used) model quota.
// Antigravity-style per-model windows: pick worst only inside requested family.
const modelWindows = Object.entries(windows).filter(
([key]) =>
key !== "credits" &&
!key.endsWith("_weekly") &&
!key.startsWith("window") &&
!key.includes("(5h)") &&
!key.includes("(7d)")
!key.includes("(7d)") &&
(requestedFamily === null ||
requestedFamily === "other" ||
getAntigravityQuotaFamily(key) === requestedFamily)
);
if (modelWindows.length > 0 && !normalized.window5h) {
const worst = modelWindows.reduce((a, b) => (a[1].percentUsed > b[1].percentUsed ? a : b));
normalized.window5h = worst[1];
}
// Antigravity-style weekly family buckets: pick the worst *_weekly quota.
const weeklyWindows = Object.entries(windows).filter(([key]) => key.endsWith("_weekly"));
// Antigravity-style weekly buckets: pick worst only inside requested family.
const weeklyWindows = Object.entries(windows).filter(([key]) => {
const hasFamilyScope = requestedFamily === "gemini" || requestedFamily === "claude";
return (
key.endsWith("_weekly") &&
(!hasFamilyScope || antigravityWeeklyWindowMatchesFamily(key, requestedFamily))
);
});
if (weeklyWindows.length > 0 && !normalized.window7d) {
const worst = weeklyWindows.reduce((a, b) => (a[1].percentUsed > b[1].percentUsed ? a : b));
normalized.window7d = worst[1];
@@ -320,18 +393,21 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection)
const provider = typeof conn.provider === "string" ? conn.provider.trim() : "";
if (!provider) return null;
const key = cacheKey(provider, connectionId);
const requestedModel =
typeof connection.requestedModel === "string" ? connection.requestedModel : undefined;
const key = cacheKey(provider, connectionId, requestedModel);
const forceKey = connectionKey(provider, connectionId);
const now = Date.now();
const forceRefresh = isPendingForceRefresh(key, now);
const forceRefresh = isPendingForceRefresh(forceKey, now);
const hit = cachedQuotaIfFresh(key, forceRefresh, now);
if (hit) return hit;
// convert-null / throw keep the force-refresh flag (agy inner caches are
// still stale) but must not hammer those endpoints on every routing tick.
if (isForceRefreshMissCooling(key, forceRefresh, now)) return null;
if (isForceRefreshMissCooling(forceKey, forceRefresh, now)) return null;
// Capture before await: a 429 during fetchUsage re-stamps this; writing
// the pre-429 snapshot would wipe that flag and recache stale quota.
const refreshStamp = pendingForceRefresh.get(key);
const refreshStamp = pendingForceRefresh.get(forceKey);
let usage: unknown;
try {
@@ -340,28 +416,29 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection)
...(forceRefresh ? { forceRefresh: true } : {}),
});
} catch {
markPendingForceRefreshMiss(key);
markPendingForceRefreshMiss(forceKey);
return null;
}
const quota = convertUsageToQuotaInfo(usage);
const quota = convertUsageToQuotaInfo(usage, { provider, requestedModel });
if (!quota) {
markPendingForceRefreshMiss(key);
markPendingForceRefreshMiss(forceKey);
return null;
}
// Concurrent 429 re-stamped a still-live flag — do not recache the
// pre-429 snapshot. A vanished or expired stamp is not a 429.
if (isConcurrentForceRefresh(key, refreshStamp)) {
if (isConcurrentForceRefresh(forceKey, refreshStamp)) {
return quota;
}
pendingForceRefresh.delete(key);
pendingForceRefreshMiss.delete(key);
pendingForceRefresh.delete(forceKey);
pendingForceRefreshMiss.delete(forceKey);
// Refresh the static window catalog so the dashboard can render the right
// modal inputs without waiting for the user to open the page.
registerQuotaWindows(provider, Object.keys(quota.windows || {}));
// Refresh the static window catalog from the unscoped usage payload so a
// family-scoped request cannot hide sibling-family dashboard controls.
const unscopedQuota = convertUsageToQuotaInfo(usage, { provider });
registerQuotaWindows(provider, Object.keys(unscopedQuota?.windows || quota.windows || {}));
cache.set(key, { quota, fetchedAt: Date.now() });
return quota;
@@ -373,13 +450,16 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection)
* fresh data instead of a 60s stale window.
*/
export function invalidateGenericQuotaCache(provider: string, connectionId: string): void {
const key = cacheKey(provider, connectionId);
cache.delete(key);
const forceKey = connectionKey(provider, connectionId);
const prefix = `${forceKey}::`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) cache.delete(key);
}
// Next fetch must bypass provider-inner usage caches (agy retrieveUserQuota /
// weekly are 60s5min). Without this, dropping the 60s wrapper recaches stale.
// TTL matches those inner caches: after 5min the flag is a no-op.
pendingForceRefresh.set(key, Date.now());
pendingForceRefreshMiss.delete(key);
pendingForceRefresh.set(forceKey, Date.now());
pendingForceRefreshMiss.delete(forceKey);
}
/**
@@ -415,3 +495,15 @@ export function registerGenericQuotaFetchers(): void {
registerQuotaFetcher(provider, fetchGenericQuota);
}
}
export const __testing = {
setUsageFetcher(fetcher: UsageFetcher): void {
usageFetcherOverride = fetcher;
},
resetUsageFetcher(): void {
usageFetcherOverride = null;
},
clearCache(): void {
cache.clear();
},
};

View File

@@ -92,8 +92,15 @@ function toNonEmptyString(value: unknown): string | null {
// (rename-robust) rather than an id allowlist: any model the account is entitled
// to whose capabilities.type is "chat" (or that carries a chat-shaped
// supported_endpoints) is kept, so a newly-entitled model shows up with no code
// change. Only explicitly non-chat rows (embeddings / completion) are dropped.
// change. Also filters out rows when policy.state is set and != "enabled", or
// when model_picker_enabled=false. Explicitly non-chat rows (embeddings /
// completion) are dropped as well.
function isRoutableChatModel(item: RawRecord): boolean {
const policy = asRecord(item.policy);
const policyState = toNonEmptyString(policy.state);
if (policyState && policyState !== "enabled") return false;
if (item.model_picker_enabled === false) return false;
const capabilities = asRecord(item.capabilities);
const capType = toNonEmptyString(capabilities.type);
if (capType) return capType === "chat";

View File

@@ -52,7 +52,6 @@ export const LEGACY_FREE_PROVIDERS: readonly string[] = [
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq",
];

View File

@@ -19,7 +19,6 @@
"longcat",
"cloudflare-ai",
"nvidia-nim",
"cerebras",
"groq"
]
}

View File

@@ -10,7 +10,7 @@
*/
import { safePercentage } from "@/shared/utils/formatting";
import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../../executors/claudeIdentity.ts";
import { getClaudeCodeVersion, fetchClaudeBootstrap } from "../../executors/claudeIdentity.ts";
import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "../claudeUsageCooldown.ts";
import { toRecord } from "./scalars.ts";
import { type UsageQuota, parseResetTime } from "./quota.ts";
@@ -71,7 +71,7 @@ export async function getClaudeUsage(accessToken?: string) {
"Accept-Encoding": "gzip, compress, deflate, br",
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`,
"User-Agent": `claude-code/${getClaudeCodeVersion()}`,
"anthropic-beta": "oauth-2025-04-20",
},
signal: ctrl.signal,

View File

@@ -327,6 +327,123 @@ function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
// Maps of schemas — the container itself is not a bare property map (#12269).
const SCHEMA_MAP_KEYS = new Set([
"properties",
"$defs",
"definitions",
"patternProperties",
"dependentSchemas",
]);
const SCHEMA_NODE_KEYS = new Set([
"additionalItems",
"additionalProperties",
"contentSchema",
"contains",
"default",
"dependencies",
"dependentRequired",
"dependentSchemas",
"discriminator",
"else",
"example",
"examples",
"externalDocs",
"if",
"patternProperties",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
"xml",
]);
function isSchemaNode(record: JsonRecord): boolean {
if (Object.keys(record).some((key) => key.startsWith("x-") || SCHEMA_NODE_KEYS.has(key))) {
return true;
}
if (typeof record.type === "string" || Array.isArray(record.type)) return true;
if (record.properties !== undefined || Array.isArray(record.required)) return true;
if (record.items !== undefined || record.prefixItems !== undefined) return true;
if (record.anyOf !== undefined || record.oneOf !== undefined || record.allOf !== undefined) {
return true;
}
if (record.not !== undefined || record.$ref !== undefined || record.enum !== undefined) {
return true;
}
return record.const !== undefined;
}
function isBarePropertyMap(record: JsonRecord): boolean {
const keys = Object.keys(record);
if (keys.length === 0 || isSchemaNode(record)) return false;
return keys.every((key) => {
const value = record[key];
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
});
}
function promoteBooleanRequired(record: JsonRecord): void {
const properties = toRecord(record.properties);
if (Object.keys(properties).length === 0) return;
const required = Array.isArray(record.required)
? record.required.filter((field): field is string => typeof field === "string")
: [];
for (const [name, schema] of Object.entries(properties)) {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) continue;
const child = schema as JsonRecord;
if (child.required === true) {
if (!required.includes(name)) required.push(name);
}
if ("required" in child && !Array.isArray(child.required)) {
delete child.required;
}
}
if (required.length > 0) {
record.required = required;
} else if (!Array.isArray(record.required)) {
delete record.required;
}
}
// Pre-pass for Cloud Code (#12269): boolean `required` on a property and nested
// bare property maps both survive the later phases and 400 Gemini's proto.
// Mirrors CLIProxyAPI normalizeMalformedSchemaObjects.
function normalizeMalformedSchemaObjects(obj: unknown, parentKey?: string): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
normalizeMalformedSchemaObjects(item, parentKey);
}
return;
}
const record = obj as JsonRecord;
if (parentKey === undefined || !SCHEMA_MAP_KEYS.has(parentKey)) {
if (isBarePropertyMap(record)) {
const props = { ...record };
for (const key of Object.keys(record)) {
delete record[key];
}
record.type = "object";
record.properties = props;
}
}
promoteBooleanRequired(record);
for (const [key, value] of Object.entries(record)) {
if (value && typeof value === "object") {
normalizeMalformedSchemaObjects(value, key);
}
}
}
function decodeJsonPointerSegment(segment: unknown): string {
return String(segment).replace(/~1/g, "/").replace(/~0/g, "~");
}
@@ -627,6 +744,9 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown {
const root = cloneSchemaValue(schema);
let cleaned = inlineLocalSchemaRefs(root, root);
// Phase 0: #12269 malformed skill/tool schemas (boolean required, bare maps).
normalizeMalformedSchemaObjects(cleaned);
// Phase 1: Convert and prepare
convertConstToEnum(cleaned);
convertEnumValuesToStrings(cleaned);

View File

@@ -323,8 +323,14 @@ export function describeMalformedNonStream(
): { message: string; code: string; type: string } {
const body = resp && typeof resp === "object" ? (resp as Record<string, unknown>) : null;
if (body?.object === "response" && body.status === "failed") {
const err = body.error && typeof body.error === "object" ? (body.error as Record<string, unknown>) : null;
const rawMessage =
typeof err?.message === "string" && err.message.trim().length > 0 ? err.message.trim() : null;
return {
message: "upstream reported a failed response without usable output",
// Trim only here; buildErrorBody (chatCore) does the single sanitization pass.
message: rawMessage
? `upstream reported a failed response: ${rawMessage}`
: "upstream reported a failed response without usable output",
code: "upstream_response_failed",
type: "upstream_response_error",
};

View File

@@ -0,0 +1,44 @@
/**
* Gateway-measured generation throughput (#12616).
*
* tok/s MUST exclude TTFT. `output_tokens / total_latency` includes queueing and
* first-token wait and is not generation speed. When TTFT is unknown (typical
* non-streaming JSON), omit the field rather than guessing.
*/
export function generationDurationMs(
totalMs: number,
ttftMs: number | null | undefined
): number | null {
if (!Number.isFinite(totalMs) || totalMs <= 0) return null;
if (ttftMs == null || !Number.isFinite(ttftMs) || ttftMs < 0) return null;
const generationMs = totalMs - ttftMs;
return generationMs > 0 ? generationMs : null;
}
export function tokensPerSecond(
outputTokens: number,
generationMs: number | null | undefined
): number | null {
if (generationMs == null || !Number.isFinite(generationMs) || generationMs <= 0) return null;
if (!Number.isFinite(outputTokens) || outputTokens <= 0) return null;
return outputTokens / (generationMs / 1000);
}
function outputTokenCount(usage: Record<string, unknown>): number {
const raw =
usage.completion_tokens ??
usage.output_tokens ??
usage.candidatesTokenCount ??
usage.outputTokens ??
usage.completionTokens;
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
return Number.isFinite(n) ? n : 0;
}
/** Attach `tokens_per_second` when generation duration (excluding TTFT) is known. */
export function attachTokensPerSecond<T>(usage: T, generationMs: number | null | undefined): T {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage;
const tps = tokensPerSecond(outputTokenCount(usage as Record<string, unknown>), generationMs);
if (tps == null) return usage;
return { ...(usage as Record<string, unknown>), tokens_per_second: Number(tps.toFixed(3)) } as T;
}

View File

@@ -28,7 +28,12 @@ export type RequestPipelinePayloads = {
type RequestLogger = {
sessionPath: null;
logClientRawRequest: (endpoint: unknown, body: unknown, headers?: HeaderInput) => void;
logClientRawRequest: (
endpoint: unknown,
body: unknown,
headers?: HeaderInput,
effectiveInput?: unknown
) => void;
logRouteDecision: (decision: unknown) => void;
logOpenAIRequest: (body: unknown) => void;
logTargetRequest: (url: unknown, headers: HeaderInput, body: unknown) => void;
@@ -392,12 +397,26 @@ export async function createRequestLogger(
return {
sessionPath: null,
logClientRawRequest(endpoint, body, headers = {}) {
logClientRawRequest(endpoint, body, headers = {}, effectiveInput) {
payloads.clientRawRequest = {
timestamp: new Date().toISOString(),
endpoint,
headers: maskSensitiveHeaders(headers),
body: cloneBoundedForLog(body),
// The actual `input` this request dispatched with, captured AFTER
// OmniRoute's own previous_response_id reconstruction (see
// src/sse/handlers/chat.ts) -- `body` above is deliberately the
// pre-reconstruction raw client bytes (captureDeferredClientRawBody's
// whole point) and is NOT what got sent for a continued turn.
// resolvePreviousResponseState must chain off this field, not
// `body.input`: reading the raw pre-reconstruction input for a
// request that was itself a continuation compounds into progressively
// truncated history a few hops deep (live incident 2026-09-03,
// manifested as a malformed request with no leading system/user
// message rejected by the upstream provider).
...(effectiveInput !== undefined
? { effectiveInput: cloneBoundedForLog(effectiveInput) }
: {}),
};
},

View File

@@ -1036,11 +1036,11 @@ export function createSSEStream(options: StreamOptions = {}) {
totalContentLength > 0
) {
const estimated = estimateUsage(body, totalContentLength, sourceFormat);
itemSanitized.usage = filterUsageForFormat(estimated, sourceFormat);
itemSanitized.usage = timing.withTps(filterUsageForFormat(estimated, sourceFormat));
state.usage = estimated;
} else if (state?.finishReason && isFinishChunk && state.usage) {
const buffered = addBufferToUsage(state.usage);
itemSanitized.usage = filterUsageForFormat(buffered, sourceFormat);
itemSanitized.usage = timing.withTps(filterUsageForFormat(buffered, sourceFormat));
}
if (
@@ -1079,8 +1079,8 @@ export function createSSEStream(options: StreamOptions = {}) {
model,
cacheHit: false,
latencyMs: Date.now() - streamStartedAt,
usage: finalUsage,
costUsd,
usage: timing.withTps(finalUsage),
costUsd, ttftMs: timing.ttftMs(),
});
if (!comment) return;
reqLogger?.appendConvertedChunk?.(comment);
@@ -2046,7 +2046,7 @@ export function createSSEStream(options: StreamOptions = {}) {
// estimate is now emitted in flush(), only when the upstream stayed silent.
if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
const buffered = addBufferToUsage(usage);
parsed.usage = filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI);
parsed.usage = timing.withTps(filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI));
output = `data: ${JSON.stringify(parsed)}\n\n`;
passthroughForwardedUsage = true;
injectedUsage = true;
@@ -2571,7 +2571,7 @@ export function createSSEStream(options: StreamOptions = {}) {
created: Math.floor(Date.now() / 1000),
model,
choices: [],
usage: filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI),
usage: timing.withTps(filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI)),
};
const usageOutput = `data: ${JSON.stringify(usageOnlyChunk)}\n\n`;
reqLogger?.appendConvertedChunk?.(usageOutput);

View File

@@ -30,6 +30,8 @@
* instances as absolute times — the same convention `earlyStreamKeepalive.ts`
* already follows on this streaming path.
*/
import { attachTokensPerSecond, generationDurationMs } from "./generationThroughput.ts";
export interface StreamTiming {
startedAt: number;
firstByteAt: number | null;
@@ -48,6 +50,10 @@ export interface StreamTiming {
avgItlMs(): number | null;
/** Time from stream start to completion (ms). */
totalMs(): number;
/**
* Attach gateway-measured tok/s (TTFT excluded). No-op when TTFT is unknown.
*/
withTps<T>(usage: T): T;
}
/** Max number of inter-chunk samples kept (bounds memory). */
@@ -88,6 +94,9 @@ export function createStreamTiming(): StreamTiming {
totalMs() {
return performance.now() - this.startedAt;
},
withTps(usage) {
return attachTokensPerSecond(usage, generationDurationMs(this.totalMs(), this.ttftMs()));
},
};
return timing;
}

View File

@@ -293,6 +293,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target
"cache_read_input_tokens",
"cache_creation_input_tokens",
"estimated",
"tokens_per_second",
],
[FORMATS.GEMINI]: [
"promptTokenCount",
@@ -301,6 +302,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target
"cachedContentTokenCount",
"thoughtsTokenCount",
"estimated",
"tokens_per_second",
],
[FORMATS.OPENAI_RESPONSES]: [
"input_tokens",
@@ -312,6 +314,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target
"cost_in_usd_ticks",
"server_side_tool_usage_details",
"server_side_tool_usage",
"tokens_per_second",
],
// OpenAI format (default for OPENAI, CODEX, KIRO, etc.)
default: [
@@ -327,6 +330,7 @@ export function filterUsageForFormat(usage: UsageLike | null | undefined, target
"cache_read_input_tokens",
"cache_creation_input_tokens",
"estimated",
"tokens_per_second",
],
};
@@ -671,9 +675,20 @@ export function hasValidUsage(usage: UsageLike | null | undefined) {
export function isEmptyUsage(usage: unknown): boolean {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return true;
const u = usage as Record<string, unknown>;
for (const k of ["prompt_tokens","completion_tokens","total_tokens","input_tokens","output_tokens","promptTokenCount","candidatesTokenCount","totalTokenCount"]) {
for (const k of [
"prompt_tokens",
"completion_tokens",
"total_tokens",
"input_tokens",
"output_tokens",
"promptTokenCount",
"candidatesTokenCount",
"totalTokenCount",
]) {
const v = u[k];
if (typeof v === "number" && Number.isFinite(v)) { if (v > 0) return false; }
if (typeof v === "number" && Number.isFinite(v)) {
if (v > 0) return false;
}
}
return true;
}

View File

@@ -22,6 +22,7 @@
"src/types/",
".env.example",
"config/i18n.json",
".npmrc",
"scripts/build/postinstall.mjs",
"scripts/build/fixPlaywrightAndroid.mjs",
"bin/cli/runtime/",
@@ -463,6 +464,7 @@
"unrs-resolver": true
},
"overrides": {
"browserslist": "^4.28.8",
"onnxruntime-node": "1.24.3",
"eslint-plugin-react-hooks": "7.1.1",
"fast-xml-parser": "^5.10.1",

View File

@@ -218,12 +218,16 @@ function readCodeFacts() {
"const t=computeFreeModelTotals();const cli=Object.values(CLI_TOOLS);",
"const by=(c)=>cli.filter(x=>x.category===c).length;",
// "Free forever" = every provider whose free access renews or needs no key at all.
// one-time-initial (signup credits) and discontinued pools are excluded on purpose.
// one-time-initial (signup credits) and discontinued pools are excluded on purpose,
// and so is every eligibility-gated row: a provider nobody can sign up for without
// clearing a gate is not "free forever" for the reader of the headline.
"const FOREVER=new Set(['recurring-monthly','recurring-daily','recurring-uncapped',",
"'recurring-credit','keyless']);",
"const ff=new Set();for(const m of t.perModel)if(FOREVER.has(m.freeType))ff.add(m.provider);",
"const ff=new Set();for(const m of t.perModel)",
"if(FOREVER.has(m.freeType)&&!m.eligibilityGate)ff.add(m.provider);",
'console.log("@@"+JSON.stringify({freeSteady:t.steadyRecurringTokens,entries:t.perModel.length,',
"freeFirst:t.firstMonthRealisticTokens,freePools:t.poolCount,engines:ENGINE_IDS.length,",
"freeFirst:t.firstMonthRealisticTokens,freeGated:t.gatedRecurringTokens,",
"freePools:t.poolCount,engines:ENGINE_IDS.length,",
"cliTotal:cli.length,cliCode:by('code'),cliAgent:by('agent'),",
"mcpTools:countUniqueMcpTools(cols),mcpScopes:sc.size,providers:pids.size,freeForever:ff.size,",
"modePacks:Object.keys(MODE_PACKS),",
@@ -271,6 +275,20 @@ export function extractHeadlineClaims(content) {
return claims;
}
// The eligibility-gated figure ("+~6M behind regional identity verification") is validated
// with its own anchor so it can neither drift nor be silently dropped once it exists.
const GATED_ANCHOR = /^\s*behind regional identity verification/i;
export function extractGatedClaims(content) {
const claims = [];
for (const m of content.matchAll(/\+?~?(\d+(?:\.\d+)?)([BM])\b/g)) {
const after = content.slice(m.index + m[0].length, m.index + m[0].length + 60);
if (!GATED_ANCHOR.test(after)) continue;
claims.push({ tokens: Number(m[1]) * (m[2] === "B" ? 1e9 : 1e6), unit: m[2], text: m[0] });
}
return claims;
}
export function checkFreeTierHeadline(content, totals) {
const claims = extractHeadlineClaims(content);
if (!claims.length) return { ok: true, detail: "no aggregate free-tier headline in this file" };
@@ -279,14 +297,31 @@ export function checkFreeTierHeadline(content, totals) {
const stale = claims.filter(
(c) => Math.abs(c.value - steady) >= 0.05 && Math.abs(c.value - first) >= 0.05
);
if (!stale.length)
return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` };
return {
ok: false,
detail:
const problems = [];
if (stale.length) {
problems.push(
`stale headline ${[...new Set(stale.map((c) => c.text))].join(", ")} — live catalog ` +
`computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`,
};
`computes ~${steady.toFixed(2)}B steady / ~${first.toFixed(2)}B first month`
);
}
if (totals.g != null && totals.g > 0) {
const gated = extractGatedClaims(content);
const tol = (c) => (c.unit === "B" ? 0.05e9 : 0.5e6);
const gatedStale = gated.filter((c) => Math.abs(c.tokens - totals.g) >= tol(c));
if (!gated.length) {
problems.push(
`missing gated figure — live catalog computes ${Math.round(totals.g / 1e6)}M behind regional identity verification`
);
} else if (gatedStale.length) {
problems.push(
`stale gated figure ${[...new Set(gatedStale.map((c) => c.text))].join(", ")} — live catalog ` +
`computes ${Math.round(totals.g / 1e6)}M behind regional identity verification`
);
}
}
if (!problems.length)
return { ok: true, detail: `${claims.length} headline claim(s) match the live catalog` };
return { ok: false, detail: problems.join("; ") };
}
// PURE: docs prose that names the product version ("OmniRoute v3.8.50 ·",
@@ -599,12 +634,12 @@ export function buildChecks() {
},
{
label: "Free-tier headline (live catalog)",
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools`,
actual: `~${(f.freeSteady / 1e9).toFixed(2)}B steady / ${f.freePools} pools / ${Math.round(f.freeGated / 1e6)}M gated`,
docKey: "free-tier headline",
strict: true,
files: ["README.md", "docs/reference/FREE_TIERS.md"],
validate: (content) =>
checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst }),
checkFreeTierHeadline(content, { s: f.freeSteady, m: f.freeFirst, g: f.freeGated }),
},
claim(
f.engines,

View File

@@ -106,6 +106,11 @@ function parsePatterns(name) {
const LOCAL_ONLY_PREFIXES = parsePrefixes("LOCAL_ONLY_API_PREFIXES");
const LOCAL_ONLY_PATTERNS = parsePatterns("LOCAL_ONLY_API_PATTERNS");
const ALWAYS_PROTECTED_PATHS = parsePrefixes("ALWAYS_PROTECTED_API_PATHS");
// isAlwaysProtectedPath() is ALSO two-armed (paths || patterns) — reading only the
// path array repeated, on this half, the very bug #12350 fixed on the LOCAL_ONLY
// half: the pattern-gated credential routes (…/{claude,codex}-auth/{export,
// apply-local}, #12600) read as unannotated even though they are protected.
const ALWAYS_PROTECTED_PATTERNS = parsePatterns("ALWAYS_PROTECTED_API_PATTERNS");
if (
LOCAL_ONLY_PREFIXES.length === 0 ||
@@ -135,6 +140,15 @@ function coveredByLocalOnly(pathStr) {
return matchesPrefix(concrete) || LOCAL_ONLY_PATTERNS.some((re) => re.test(concrete));
}
/** Mirror of routeGuard.isAlwaysProtectedPath() — both arms, same order. */
function coveredByAlwaysProtected(pathStr) {
const concrete = concretize(pathStr);
return (
ALWAYS_PROTECTED_PATHS.some((p) => concrete === p || concrete.startsWith(`${p}/`)) ||
ALWAYS_PROTECTED_PATTERNS.some((re) => re.test(concrete))
);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
const errors = [];
@@ -151,16 +165,11 @@ for (const [pathStr, methods] of Object.entries(paths)) {
);
}
if (spec["x-always-protected"] === true) {
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
if (spec["x-always-protected"] === true && !coveredByAlwaysProtected(pathStr)) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT covered by ` +
`ALWAYS_PROTECTED_API_PATHS or ALWAYS_PROTECTED_API_PATTERNS`
);
if (!matchesPath) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
);
}
}
}
}

View File

@@ -1,56 +1,81 @@
// Generates docs/screenshots/free-tier-budget-card.svg from the per-model catalog.
// Run: node scripts/research/gen-budget-card-svg.mjs
#!/usr/bin/env node
// Generates the free-tier budget card from the per-model catalog, through the
// same function the docs gate and the dashboard use — never by parsing the data
// file with a regex (that silently skipped every row carrying an extra field).
// Run from the repo root:
// node --import tsx/esm scripts/research/gen-budget-card-svg.mjs [--out path.svg]
import fs from "node:fs";
import { computeFreeModelTotals } from "../../open-sse/config/freeModelCatalog.ts";
const txt = fs.readFileSync("open-sse/config/freeModelCatalog.data.ts", "utf8");
const recs = [
...txt.matchAll(
/\{ provider: "([^"]+)", modelId: "([^"]+)", displayName: "([^"]+)", monthlyTokens: (\d+), creditTokens: (\d+), freeType: "([^"]+)", poolKey: (null|"[^"]+"), tos: "([^"]+)" \}/g
),
].map((m) => ({
provider: m[1],
modelId: m[2],
displayName: m[3],
monthlyTokens: +m[4],
creditTokens: +m[5],
freeType: m[6],
poolKey: m[7] === "null" ? null : m[7].slice(1, -1),
tos: m[8],
}));
const outIdx = process.argv.indexOf("--out");
if (outIdx >= 0 && !process.argv[outIdx + 1]) throw new Error("--out requires a path");
const OUT = outIdx >= 0 ? process.argv[outIdx + 1] : "docs/screenshots/free-tier-budget-card.svg";
const t = computeFreeModelTotals();
const STEADY_TYPES = new Set(["recurring-daily", "recurring-monthly", "keyless"]);
const fmt = (n) =>
n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : n >= 1e6 ? Math.round(n / 1e6) + "M" : Math.round(n / 1e3) + "K";
n >= 1e9
? (n / 1e9).toFixed(2) + "B"
: n >= 1e6
? Math.round(n / 1e6) + "M"
: Math.round(n / 1e3) + "K";
// One bar segment per steady pool (largest member), gated rows excluded like the headline.
const poolMap = new Map();
for (const r of recs) {
if (!["recurring-daily", "recurring-monthly", "keyless"].includes(r.freeType)) continue;
for (const r of t.perModel) {
if (!STEADY_TYPES.has(r.freeType) || r.eligibilityGate) continue;
const k = r.poolKey || `${r.provider}:${r.modelId}`;
const cur = poolMap.get(k);
if (!cur || r.monthlyTokens > cur.monthlyTokens) poolMap.set(k, r);
}
const pools = [...poolMap.values()].filter((r) => r.monthlyTokens > 0).sort((a, b) => b.monthlyTokens - a.monthlyTokens);
const steady = pools.reduce((s, r) => s + r.monthlyTokens, 0);
const pools = [...poolMap.values()]
.filter((r) => r.monthlyTokens > 0)
.sort((a, b) => b.monthlyTokens - a.monthlyTokens);
const steady = t.steadyRecurringTokens;
const firstMonth = t.firstMonthRealisticTokens;
const gated = t.gatedRecurringTokens;
const otMap = new Map();
for (const r of recs) {
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0) continue;
for (const r of t.perModel) {
// Gated rows are excluded here too — they are absent from firstMonthRealisticTokens.
if (r.freeType !== "one-time-initial" || r.creditTokens <= 0 || r.eligibilityGate) continue;
const k = r.poolKey || r.provider;
otMap.set(k, { provider: r.provider, v: Math.max(otMap.get(k)?.v || 0, r.creditTokens) });
}
const oneTime = [...otMap.values()].sort((a, b) => b.v - a.v);
const oneTimeSum = oneTime.reduce((s, r) => s + r.v, 0);
const firstMonth = steady + oneTimeSum;
const avoidProviders = [...new Set(recs.filter((r) => r.tos === "avoid").map((r) => r.provider))].length;
const uncappedProviders = [...new Set(recs.filter((r) => r.freeType === "recurring-uncapped").map((r) => r.provider))];
const avoidProviders = new Set(t.perModel.filter((r) => r.tos === "avoid").map((r) => r.provider))
.size;
const uncappedProviders = t.uncappedProviders;
const GRID = pools.slice(0, 28);
const STRIP = oneTime.slice(0, 9);
const PAL = ["#6c5ce7","#00b894","#0984e3","#e17055","#fdcb6e","#e84393","#00cec9","#d63031","#a29bfe","#55efc4","#74b9ff","#ffeaa7","#fab1a0","#81ecec"];
const PAL = [
"#6c5ce7",
"#00b894",
"#0984e3",
"#e17055",
"#fdcb6e",
"#e84393",
"#00cec9",
"#d63031",
"#a29bfe",
"#55efc4",
"#74b9ff",
"#ffeaa7",
"#fab1a0",
"#81ecec",
];
const color = (i) => PAL[i % PAL.length];
const cleanName = (r) => (r.displayName || r.provider).replace(/\s*\(.*$/, "").replace(/ —.*$/, "").slice(0, 24);
const cleanName = (r) =>
(r.displayName || r.provider)
.replace(/\s*\(.*$/, "")
.replace(/ —.*$/, "")
.slice(0, 24);
// bar segments (min width so every pool shows)
const BAR_X = 32, BAR_W = 836, MIN = 7;
const BAR_X = 32,
BAR_W = 836,
MIN = 7;
const extra = BAR_W - MIN * GRID.length;
let bx = BAR_X;
const segs = GRID.map((r, i) => {
@@ -60,11 +85,13 @@ const segs = GRID.map((r, i) => {
return s;
});
const B = []; // body elements
// title
B.push(`<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>`);
B.push(`<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">${pools.length} free pools · ${recs.length} models · one endpoint</text>`);
// stats
const B = [];
B.push(
`<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>`
);
B.push(
`<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">${pools.length} free pools · ${t.modelCount} models · one endpoint</text>`
);
const stat = (sx, label, val, vc) => {
B.push(`<text x="${sx}" y="84" fill="#7d8590" font-size="11.5">${label}</text>`);
B.push(`<text x="${sx}" y="114" fill="${vc}" font-size="27" font-weight="800">${val}</text>`);
@@ -72,50 +99,90 @@ const stat = (sx, label, val, vc) => {
stat(32, "Steady / month", `~${fmt(steady)}`, "#e6edf3");
stat(330, "First month (+ signup credits)", `~${fmt(firstMonth)}`, "#3fb950");
stat(700, "ToS-flagged (you decide)", `${avoidProviders} providers`, "#d29922");
// bar
B.push(`<clipPath id="bar"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" rx="8"/></clipPath>`);
B.push(`<g clip-path="url(#bar)"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" fill="#21262d"/>`);
for (const s of segs) B.push(`<rect x="${s.x.toFixed(1)}" y="132" width="${(s.w + 0.6).toFixed(1)}" height="16" fill="${s.c}"/>`);
B.push(
`<clipPath id="bar"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" rx="8"/></clipPath>`
);
B.push(
`<g clip-path="url(#bar)"><rect x="${BAR_X}" y="132" width="${BAR_W}" height="16" fill="#21262d"/>`
);
for (const s of segs)
B.push(
`<rect x="${s.x.toFixed(1)}" y="132" width="${(s.w + 0.6).toFixed(1)}" height="16" fill="${s.c}"/>`
);
B.push(`</g>`);
B.push(`<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>`);
// model grid 4 cols
const COLS = 4, COLW = 213, GX = 32, GY = 200, RH = 30;
B.push(
`<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one free pool · widths floored so every provider shows · honest numbers in the grid.</text>`
);
const COLS = 4,
COLW = 213,
GX = 32,
GY = 200,
RH = 30;
GRID.forEach((r, i) => {
const col = i % COLS, row = (i / COLS) | 0;
const cx = GX + col * COLW, cy = GY + row * RH;
const col = i % COLS,
row = (i / COLS) | 0;
const cx = GX + col * COLW,
cy = GY + row * RH;
B.push(`<circle cx="${cx + 5}" cy="${cy - 4}" r="5" fill="${color(i)}"/>`);
B.push(`<text x="${cx + 16}" y="${cy}" fill="#c9d1d9" font-size="12.5">${cleanName(r)} <tspan fill="#7d8590">${fmt(r.monthlyTokens)}</tspan></text>`);
B.push(
`<text x="${cx + 16}" y="${cy}" fill="#c9d1d9" font-size="12.5">${cleanName(r)} <tspan fill="#7d8590">${fmt(r.monthlyTokens)}</tspan></text>`
);
});
let y = GY + Math.ceil(GRID.length / COLS) * RH + 6;
// first-month strip (wrapping)
B.push(`<line x1="32" y1="${y}" x2="868" y2="${y}" stroke="#30363d"/>`);
y += 26;
B.push(`<text x="32" y="${y}" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~${fmt(oneTimeSum)})</text>`);
B.push(
`<text x="32" y="${y}" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~${fmt(oneTimeSum)})</text>`
);
y += 24;
let sxp = 32;
for (const r of STRIP) {
const label = `${r.provider} ${fmt(r.v)}`;
const w = 16 + label.length * 6.7;
if (sxp + w > 862) { sxp = 32; y += 30; }
B.push(`<rect x="${sxp.toFixed(0)}" y="${(y - 15).toFixed(0)}" width="${w.toFixed(0)}" height="22" rx="11" fill="#13311f" stroke="#238636"/>`);
B.push(`<text x="${(sxp + w / 2).toFixed(0)}" y="${y.toFixed(0)}" fill="#7ee787" font-size="11.5" text-anchor="middle">${label}</text>`);
if (sxp + w > 862) {
sxp = 32;
y += 30;
}
B.push(
`<rect x="${sxp.toFixed(0)}" y="${(y - 15).toFixed(0)}" width="${w.toFixed(0)}" height="22" rx="11" fill="#13311f" stroke="#238636"/>`
);
B.push(
`<text x="${(sxp + w / 2).toFixed(0)}" y="${y.toFixed(0)}" fill="#7ee787" font-size="11.5" text-anchor="middle">${label}</text>`
);
sxp += w + 8;
}
y += 26;
// ToS note (softened)
B.push(`<rect x="32" y="${y}" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>`);
B.push(`<text x="46" y="${(y + 14).toFixed(0)}" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>`);
B.push(`<text x="46" y="${(y + 28).toFixed(0)}" fill="#7d8590" font-size="11.5">+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +24M/mo.</text>`);
y += 34;
const H = y + 24; // card content bottom
const noteH = gated > 0 ? 48 : 34;
B.push(
`<rect x="32" y="${y}" width="836" height="${noteH}" rx="8" fill="#1c2230" stroke="#30363d"/>`
);
B.push(
`<text x="46" y="${(y + 14).toFixed(0)}" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>`
);
B.push(
`<text x="46" y="${(y + 28).toFixed(0)}" fill="#7d8590" font-size="11.5">+ ${uncappedProviders.length} permanently-free, no-cap providers (e.g. ${uncappedProviders.slice(0, 3).join(", ")}) · OpenRouter $10 → +${fmt(t.boostMonthlyTokens)}/mo.</text>`
);
if (gated > 0) {
B.push(
`<text x="46" y="${(y + 42).toFixed(0)}" fill="#d29922" font-size="11.5">+ ~${fmt(gated)} behind regional identity verification (${t.gatedProviders.join(", ")}) — real quota, never in the headline.</text>`
);
}
y += noteH;
const H = y + 24;
const CANVAS = H + 16;
const out = [];
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="900" height="${CANVAS}" viewBox="0 0 900 ${CANVAS}" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">`);
out.push(
`<svg xmlns="http://www.w3.org/2000/svg" width="900" height="${CANVAS}" viewBox="0 0 900 ${CANVAS}" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">`
);
out.push(`<rect width="900" height="${CANVAS}" rx="16" fill="#0d1117"/>`);
out.push(`<rect x="16" y="16" width="868" height="${H}" rx="13" fill="#161b22" stroke="#30363d"/>`);
out.push(`<text x="868" y="${(H + 8).toFixed(0)}" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>`);
out.push(
`<text x="868" y="${(H + 8).toFixed(0)}" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>`
);
out.push(...B);
out.push(`</svg>`);
fs.writeFileSync("docs/screenshots/free-tier-budget-card.svg", out.join("\n") + "\n");
console.log(`SVG: ${GRID.length} models, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} oneTime=${fmt(oneTimeSum)}`);
fs.writeFileSync(OUT, out.join("\n") + "\n");
console.log(
`SVG → ${OUT}: ${GRID.length} pools, ${STRIP.length} first-month chips, canvas ${CANVAS}px. steady=${fmt(steady)} firstMonth=${fmt(firstMonth)} gated=${fmt(gated)} oneTime=${fmt(oneTimeSum)}`
);

View File

@@ -1,6 +1,15 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react";
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useSyncExternalStore,
memo,
Suspense,
} from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
@@ -388,6 +397,42 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
// The dismissal lives in localStorage, which SSR cannot read: a lazy useState
// initializer would render "not dismissed" on the server and the real value on
// the client, and correcting that in an effect is a synchronous setState inside
// an effect (react-hooks/set-state-in-effect) that costs an extra commit of this
// whole tree. useSyncExternalStore is the sanctioned shape for exactly this —
// getServerSnapshot supplies the SSR-safe default, getSnapshot reads the store
// after hydration, and the two handlers below notify subscribers instead of
// setting state. The `storage` listener keeps other tabs in sync for free.
const usageGuideListeners = new Set<() => void>();
function subscribeUsageGuide(onStoreChange: () => void): () => void {
usageGuideListeners.add(onStoreChange);
globalThis.addEventListener?.("storage", onStoreChange);
return () => {
usageGuideListeners.delete(onStoreChange);
globalThis.removeEventListener?.("storage", onStoreChange);
};
}
function emitUsageGuideChange(): void {
for (const listener of usageGuideListeners) listener();
}
function getUsageGuideSnapshot(): boolean {
try {
return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1";
} catch {
// Storage access errors (privacy mode / restricted environments) show the guide.
return true;
}
}
function getUsageGuideServerSnapshot(): boolean {
return true;
}
// Pure predicate hoisted out of the page component to keep its cyclomatic budget flat
// (check:complexity new-code mode).
function isStaleIntelligentSelection(
@@ -766,16 +811,18 @@ function CombosPageContent() {
// real stored value -- exactly the kind of source React's hydration
// mismatch check is built to catch, and in dev mode a mismatch forces a
// full client-only re-render of this tree, discarding whatever the fetch
// effects below had already populated. Start with the SSR-safe default on
// both passes and correct it client-only, after hydration, in an effect.
const [showUsageGuide, setShowUsageGuide] = useState(true);
useEffect(() => {
try {
setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1");
} catch {
// Ignore storage access errors (privacy mode / restricted environments)
}
}, []);
// effects below had already populated. useSyncExternalStore renders the
// SSR-safe default on both passes and switches to the stored value at
// hydration, without a second commit — see the store helpers above.
const usageGuideNotDismissed = useSyncExternalStore(
subscribeUsageGuide,
getUsageGuideSnapshot,
getUsageGuideServerSnapshot
);
// "Hide" (as opposed to "hide forever") is intentionally per-mount: it is not
// persisted, and remounting the page brings the guide back — same as before.
const [usageGuideHiddenForNow, setUsageGuideHiddenForNow] = useState(false);
const showUsageGuide = usageGuideNotDismissed && !usageGuideHiddenForNow;
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
const [creatingKimiPreset, setCreatingKimiPreset] = useState(false);
const [comboDragIndex, setComboDragIndex] = useState(null);
@@ -1006,17 +1053,18 @@ function CombosPageContent() {
};
const handleHideUsageGuideForever = () => {
setShowUsageGuide(false);
try {
globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1");
} catch {}
emitUsageGuideChange();
};
const handleShowUsageGuide = () => {
setShowUsageGuide(true);
try {
globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY);
} catch {}
setUsageGuideHiddenForNow(false);
emitUsageGuideChange();
};
const handleFilterChange = (nextFilter) => {
@@ -1149,7 +1197,7 @@ function CombosPageContent() {
{showUsageGuide && (
<ComboUsageGuide
onHide={() => setShowUsageGuide(false)}
onHide={() => setUsageGuideHiddenForNow(true)}
onHideForever={handleHideUsageGuideForever}
onCreateCombo={() => setShowCreateModal(true)}
/>
@@ -2868,7 +2916,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
{ model: "if/qwen3-coder-plus", weight: 0 },
{ model: "if/deepseek-v3.2", weight: 0 },
{ model: "nvidia/llama-3.3-70b-instruct", weight: 0 },
{ model: "groq/llama-3.3-70b-versatile", weight: 0 },
{ model: "groq/openai/gpt-oss-120b", weight: 0 },
];
const PAID_PREMIUM_PRESET_MODELS = [

View File

@@ -33,6 +33,10 @@ export interface FreeBudgetData {
boostMonthlyTokens?: number;
/** Providers that are permanently free but publish no token cap (rate/concurrency-limited). */
uncappedProviders?: string[];
/** Pool-deduped tokens/mo behind a regional identity check — real quota, never in the headline. */
gatedRecurringTokens?: number;
/** Providers behind that check. */
gatedProviders?: string[];
headline?: string;
/** ISO timestamp of the last catalog update. Absent/null → freshness is not shown. */
catalogUpdatedAt?: string | null;
@@ -88,6 +92,7 @@ interface FreeBudgetLabels {
segmentHint: string;
boost: (tokens: string) => string;
uncapped: string;
gated: (tokens: string) => string;
tosRestricted: (count: number) => string;
provider: string;
model: string;
@@ -111,6 +116,8 @@ const DEFAULT_LABELS: FreeBudgetLabels = {
`Unlock ~${tokens} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)`,
uncapped:
"Permanently free, no published cap (rate-limited) — real access, not counted in the headline:",
gated: (tokens) =>
`~${tokens}/mo more behind a regional identity check — real quota, not counted in the headline:`,
tosRestricted: (count) =>
`${count} model${count === 1 ? "" : "s"} flagged as ToS-restricted — you decide`,
provider: "Provider",
@@ -339,6 +346,8 @@ export function FreeBudgetView({
perModel,
boostMonthlyTokens = 0,
uncappedProviders = [],
gatedRecurringTokens = 0,
gatedProviders = [],
catalogUpdatedAt,
noCredentialProviders = [],
} = data;
@@ -429,9 +438,7 @@ export function FreeBudgetView({
<span className="material-symbols-outlined text-[14px] text-emerald-500">
lock_open
</span>
<span className="text-[11px] font-semibold text-emerald-500">
{labels.noApiKey}
</span>
<span className="text-[11px] font-semibold text-emerald-500">{labels.noApiKey}</span>
<span className="text-[10.5px] text-text-muted">
({keylessModels.length} · {keylessProviders.length})
</span>
@@ -475,6 +482,23 @@ export function FreeBudgetView({
</div>
</div>
)}
{gatedRecurringTokens > 0 && (
<div className="mx-3 mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
<span className="text-[11px] text-amber-600 dark:text-amber-400">
{labels.gated(fmt(gatedRecurringTokens))}
</span>
<div className="mt-1 flex flex-wrap gap-1">
{gatedProviders.map((p) => (
<span
key={p}
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-[10.5px] text-text-muted tabular-nums"
>
{p}
</span>
))}
</div>
</div>
)}
{/* ToS-restricted callout */}
{avoidModels.length > 0 && (
@@ -669,6 +693,7 @@ export default function FreeBudgetCard() {
segmentHint: t("segmentHint"),
boost: (tokens) => t("boost", { tokens }),
uncapped: t("uncapped"),
gated: (tokens) => t("gated", { tokens }),
tosRestricted: (count) => t("tosRestricted", { count }),
provider: t("provider"),
model: t("model"),

View File

@@ -46,6 +46,7 @@ function toBudgetEntry(entry: MergedEntry): FreeModelBudget & { enabled?: boolea
poolKey: entry.poolKey,
tos: entry.tos,
trainsOnPrompts: entry.trainsOnPrompts,
eligibilityGate: entry.eligibilityGate,
hardStopGuaranteed: HARD_STOP_BY_KEY.get(`${entry.provider}:${entry.modelId}`),
enabled: entry.enabled,
};

View File

@@ -71,6 +71,9 @@ export async function POST(request) {
}
const {
name,
modelAccessMode,
allowedModels,
allowedCombos,
noLog,
scopes,
allowedConnections,
@@ -84,7 +87,12 @@ export async function POST(request) {
// Always get machineId from server
const machineId = await getConsistentMachineId();
const normalizedScopes = normalizeSelfServiceScopesForCreate(scopes);
const apiKey = await createApiKey(name, machineId, normalizedScopes, { allowedConnections });
const apiKey = await createApiKey(name, machineId, normalizedScopes, {
modelAccessMode,
allowedModels,
allowedCombos,
allowedConnections,
});
if (
noLog === true ||
allowUsageCommand === true ||
@@ -119,6 +127,9 @@ export async function POST(request) {
name: apiKey.name,
id: apiKey.id,
machineId: apiKey.machineId,
modelAccessMode: apiKey.modelAccessMode,
allowedModels: apiKey.allowedModels,
allowedCombos: apiKey.allowedCombos,
allowedConnections: apiKey.allowedConnections,
noLog: noLog === true,
allowUsageCommand: allowUsageCommand === true,

View File

@@ -14,14 +14,23 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
* Returns system info, provider health (circuit breakers),
* rate limit status, and database stats.
*/
// §8.2 optimization: short-TTL cache for the health payload. Health is a
// frequently-polled endpoint and rebuilding it every request (DB reads +
// status aggregation across 8 subsystems) is wasteful under rapid polling. 1s
// stays near-real-time for monitoring; the cache is invalidated on DELETE
// (circuit-breaker reset) so a manual reset is reflected immediately.
// §8.2 / #12532: short-TTL cache with stale-while-revalidate. Health is a
// frequently-polled endpoint; rebuilding it on the request path (DB reads +
// status aggregation) shares the event loop with GET /healthz. After the first
// fill, scrapes always receive the last payload immediately. An expired entry
// is refreshed in the background — never by awaiting live credential probes.
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
let healthPayloadRefreshInFlight = false;
let healthPayloadCacheGeneration = 0;
const HEALTH_PAYLOAD_TTL_MS = 1000;
/** Test-only: drop the in-process health payload cache. */
export function __test_resetMonitoringHealthPayloadCache(): void {
healthPayloadCache = null;
healthPayloadRefreshInFlight = false;
healthPayloadCacheGeneration += 1;
}
// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
// node version, pid, memory, provider config). An anonymous caller — the common
// case on a keyless install, and what a liveness/load-balancer probe needs — gets
@@ -34,15 +43,70 @@ function publicHealthView(payload: unknown): Record<string, unknown> {
};
}
function serveHealthPayload(fullView: boolean, payload: unknown) {
return NextResponse.json(fullView ? payload : publicHealthView(payload));
}
function scheduleHealthPayloadRefresh(): void {
if (healthPayloadRefreshInFlight) return;
healthPayloadRefreshInFlight = true;
setImmediate(() => {
rebuildHealthPayload()
.catch((error) => {
console.warn(
"[API] GET /api/monitoring/health background refresh failed:",
error instanceof Error ? error.message : error
);
})
.finally(() => {
healthPayloadRefreshInFlight = false;
});
});
}
export async function GET(request: Request) {
const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
const cachedNow = Date.now();
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
return NextResponse.json(
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
);
if (healthPayloadCache) {
if (cachedNow > healthPayloadCache.expiresAt) {
scheduleHealthPayloadRefresh();
}
return serveHealthPayload(fullView, healthPayloadCache.payload);
}
try {
const payload = await rebuildHealthPayload();
return serveHealthPayload(fullView, payload);
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({
status: "degraded",
error: "Health check partially unavailable",
timestamp: new Date().toISOString(),
providerBreakers: [],
providerHealth: {},
rateLimitStatus: {},
learnedLimits: {},
lockouts: [],
quotaMonitor: {
active: 0,
alerting: 0,
exhausted: 0,
errors: 0,
statusCounts: { starting: 0, idle: 0, healthy: 0, warning: 0, exhausted: 0, error: 0 },
byProvider: {},
monitors: [],
},
sessions: { activeCount: 0, stickyBoundCount: 0, byApiKey: {}, top: [] },
adaptiveAdmission: null,
chatAdmission: null,
dedup: { inflightRequests: 0 },
});
}
}
async function rebuildHealthPayload(): Promise<unknown> {
const generation = healthPayloadCacheGeneration;
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
try {
return reader();
@@ -64,179 +128,150 @@ export async function GET(request: Request) {
byProvider: {},
};
try {
const [
circuitBreakerModule,
rateLimitModule,
accountFallbackModule,
requestDedupModule,
quotaMonitorModule,
sessionManagerModule,
credentialHealthModule,
localHealthModule,
adaptiveAdmissionModule,
chatAdmissionModule,
settingsResult,
connectionsResult,
] = await Promise.allSettled([
import("@/shared/utils/circuitBreaker"),
import("@omniroute/open-sse/services/rateLimitManager"),
import("@omniroute/open-sse/services/accountFallback"),
import("@omniroute/open-sse/services/requestDedup.ts"),
import("@omniroute/open-sse/services/quotaMonitor.ts"),
import("@omniroute/open-sse/services/sessionManager.ts"),
import("@/lib/credentialHealth/cache"),
import("@/lib/localHealthCheck"),
import("@omniroute/open-sse/services/admission/runtime.ts"),
import("@/shared/middleware/chatBodyAdmission"),
getCachedSettings(),
getProviderConnections(),
]);
const [
circuitBreakerModule,
rateLimitModule,
accountFallbackModule,
requestDedupModule,
quotaMonitorModule,
sessionManagerModule,
credentialHealthModule,
localHealthModule,
adaptiveAdmissionModule,
chatAdmissionModule,
settingsResult,
connectionsResult,
] = await Promise.allSettled([
import("@/shared/utils/circuitBreaker"),
import("@omniroute/open-sse/services/rateLimitManager"),
import("@omniroute/open-sse/services/accountFallback"),
import("@omniroute/open-sse/services/requestDedup.ts"),
import("@omniroute/open-sse/services/quotaMonitor.ts"),
import("@omniroute/open-sse/services/sessionManager.ts"),
import("@/lib/credentialHealth/cache"),
import("@/lib/localHealthCheck"),
import("@omniroute/open-sse/services/admission/runtime.ts"),
import("@/shared/middleware/chatBodyAdmission"),
getCachedSettings(),
getProviderConnections(),
]);
const circuitBreakers =
circuitBreakerModule.status === "fulfilled"
? readHealthValue(
"circuit breakers",
() => circuitBreakerModule.value.getAllCircuitBreakerStatuses(),
[]
)
: [];
const rateLimitStatus =
rateLimitModule.status === "fulfilled"
? readHealthValue("rate limits", () => rateLimitModule.value.getAllRateLimitStatus(), {})
: {};
const learnedLimits =
rateLimitModule.status === "fulfilled"
? readHealthValue("learned limits", () => rateLimitModule.value.getLearnedLimits(), {})
: {};
const lockouts =
accountFallbackModule.status === "fulfilled"
? readHealthValue(
"model lockouts",
() => accountFallbackModule.value.getAllModelLockouts(),
[]
)
: [];
const quotaMonitorSummary =
quotaMonitorModule.status === "fulfilled"
? readHealthValue(
"quota monitor summary",
() => quotaMonitorModule.value.getQuotaMonitorSummary(),
fallbackQuotaMonitorSummary
)
: fallbackQuotaMonitorSummary;
const quotaMonitorMonitors =
quotaMonitorModule.status === "fulfilled"
? readHealthValue(
"quota monitor snapshots",
() => quotaMonitorModule.value.getQuotaMonitorSnapshots(),
[]
)
: [];
const activeSessions =
sessionManagerModule.status === "fulfilled"
? readHealthValue(
"active sessions",
() => sessionManagerModule.value.getActiveSessions(),
[]
)
: [];
const activeSessionsByKey =
sessionManagerModule.status === "fulfilled"
? readHealthValue(
"active sessions by key",
() => sessionManagerModule.value.getAllActiveSessionCountsByKey(),
{}
)
: {};
const credentialHealth =
credentialHealthModule.status === "fulfilled"
? readHealthValue(
"credential health",
() => credentialHealthModule.value.getCredentialHealthSummary(),
undefined
)
: undefined;
const localProviders =
localHealthModule.status === "fulfilled"
? readHealthValue(
"local providers",
() => localHealthModule.value.getAllHealthStatuses(),
{}
)
: {};
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : {};
const connections = connectionsResult.status === "fulfilled" ? connectionsResult.value : [];
const adaptiveAdmission =
adaptiveAdmissionModule.status === "fulfilled"
? readHealthValue(
"adaptive admission",
() => adaptiveAdmissionModule.value.getAdaptiveAdmissionRuntime().snapshot(),
null
)
: null;
// #11244: the STRUCTURAL admission gate (chatBodyAdmission.ts — bounded
// heavyweight lease + shed counters), exposed next to but distinct from the
// adaptive shadow-mode snapshot above. Additive key — nothing existing moves.
const chatAdmission =
chatAdmissionModule.status === "fulfilled"
? readHealthValue(
"chat admission",
() => chatAdmissionModule.value.perConnectionAdmissionController.snapshot(),
null
)
: null;
const circuitBreakers =
circuitBreakerModule.status === "fulfilled"
? readHealthValue(
"circuit breakers",
() => circuitBreakerModule.value.getAllCircuitBreakerStatuses(),
[]
)
: [];
const rateLimitStatus =
rateLimitModule.status === "fulfilled"
? readHealthValue("rate limits", () => rateLimitModule.value.getAllRateLimitStatus(), {})
: {};
const learnedLimits =
rateLimitModule.status === "fulfilled"
? readHealthValue("learned limits", () => rateLimitModule.value.getLearnedLimits(), {})
: {};
const lockouts =
accountFallbackModule.status === "fulfilled"
? readHealthValue(
"model lockouts",
() => accountFallbackModule.value.getAllModelLockouts(),
[]
)
: [];
const quotaMonitorSummary =
quotaMonitorModule.status === "fulfilled"
? readHealthValue(
"quota monitor summary",
() => quotaMonitorModule.value.getQuotaMonitorSummary(),
fallbackQuotaMonitorSummary
)
: fallbackQuotaMonitorSummary;
const quotaMonitorMonitors =
quotaMonitorModule.status === "fulfilled"
? readHealthValue(
"quota monitor snapshots",
() => quotaMonitorModule.value.getQuotaMonitorSnapshots(),
[]
)
: [];
const activeSessions =
sessionManagerModule.status === "fulfilled"
? readHealthValue("active sessions", () => sessionManagerModule.value.getActiveSessions(), [])
: [];
const activeSessionsByKey =
sessionManagerModule.status === "fulfilled"
? readHealthValue(
"active sessions by key",
() => sessionManagerModule.value.getAllActiveSessionCountsByKey(),
{}
)
: {};
const credentialHealth =
credentialHealthModule.status === "fulfilled"
? readHealthValue(
"credential health",
() => credentialHealthModule.value.getCachedCredentialHealthSummary(),
undefined
)
: undefined;
const localProviders =
localHealthModule.status === "fulfilled"
? readHealthValue("local providers", () => localHealthModule.value.getAllHealthStatuses(), {})
: {};
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : {};
const connections = connectionsResult.status === "fulfilled" ? connectionsResult.value : [];
const adaptiveAdmission =
adaptiveAdmissionModule.status === "fulfilled"
? readHealthValue(
"adaptive admission",
() => adaptiveAdmissionModule.value.getAdaptiveAdmissionRuntime().snapshot(),
null
)
: null;
// #11244: the STRUCTURAL admission gate (chatBodyAdmission.ts — bounded
// heavyweight lease + shed counters), exposed next to but distinct from the
// adaptive shadow-mode snapshot above. Additive key — nothing existing moves.
const chatAdmission =
chatAdmissionModule.status === "fulfilled"
? readHealthValue(
"chat admission",
() => chatAdmissionModule.value.perConnectionAdmissionController.snapshot(),
null
)
: null;
const payload = buildHealthPayload({
appVersion: APP_CONFIG.version,
// #10427: surface the artifact's git SHA so a deployment can be audited over HTTP
// instead of SSH + grepping compiled chunks (the 2026-08-14 gateway outage).
buildSha: readRunningBuildSha(),
catalogCount: Object.keys(AI_PROVIDERS).length,
settings,
connections,
circuitBreakers,
rateLimitStatus,
learnedLimits,
lockouts,
localProviders,
inflightRequests:
requestDedupModule.status === "fulfilled"
? readHealthValue(
"inflight requests",
() => requestDedupModule.value.getInflightCount(),
0
)
: 0,
quotaMonitorSummary,
quotaMonitorMonitors,
activeSessions,
activeSessionsByKey,
credentialHealth,
adaptiveAdmission,
chatAdmission,
});
const payload = buildHealthPayload({
appVersion: APP_CONFIG.version,
// #10427: surface the artifact's git SHA so a deployment can be audited over HTTP
// instead of SSH + grepping compiled chunks (the 2026-08-14 gateway outage).
buildSha: readRunningBuildSha(),
catalogCount: Object.keys(AI_PROVIDERS).length,
settings,
connections,
circuitBreakers,
rateLimitStatus,
learnedLimits,
lockouts,
localProviders,
inflightRequests:
requestDedupModule.status === "fulfilled"
? readHealthValue("inflight requests", () => requestDedupModule.value.getInflightCount(), 0)
: 0,
quotaMonitorSummary,
quotaMonitorMonitors,
activeSessions,
activeSessionsByKey,
credentialHealth,
adaptiveAdmission,
chatAdmission,
});
if (generation === healthPayloadCacheGeneration) {
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
return NextResponse.json(fullView ? payload : publicHealthView(payload));
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({
status: "degraded",
error: "Health check partially unavailable",
timestamp: new Date().toISOString(),
providerBreakers: [],
providerHealth: {},
rateLimitStatus: {},
learnedLimits: {},
lockouts: [],
quotaMonitor: { ...fallbackQuotaMonitorSummary, monitors: [] },
sessions: { activeCount: 0, stickyBoundCount: 0, byApiKey: {}, top: [] },
adaptiveAdmission: null,
chatAdmission: null,
dedup: { inflightRequests: 0 },
});
}
return payload;
}
/**

View File

@@ -8,7 +8,11 @@ import {
resetAllPricing,
} from "@/lib/db/settings";
import { updatePricingSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
formatValidationMessage,
isValidationFailure,
validateBody,
} from "@/shared/validation/helpers";
/**
* GET /api/pricing
@@ -59,7 +63,15 @@ export async function PATCH(request) {
try {
const validation = validateBody(updatePricingSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
// #12494: PricingTab reads this payload as `{ error?: string }` and feeds it
// straight to `new Error(...)`, so handing back the `{ message, details }`
// object rendered as "Falha ao salvar preços: [object Object]". Send a string.
// `formatValidationMessage` names the offending field ("field: reason") instead
// of the bare "Invalid request" constant, so the toast stays actionable (#10849).
return NextResponse.json(
{ error: formatValidationMessage(validation.error) },
{ status: 400 }
);
}
const body = validation.data;

View File

@@ -135,35 +135,27 @@ export type CatalogCacheOptions = {
*/
export const CATALOG_CACHE_TTL_MS_DEFAULT = 60_000;
/**
* Per-call knobs for {@link resolveCachedCatalogResponse}.
*
* `hideAutoCombos` / `hideNoThinkVariants` are catalog-shape dimensions folded into
* the cache key. `getStaleWhileRevalidateMs` and `scheduleBackgroundRefresh` are the
* injection points restored in #11551: the route wires Next's `after()` so the
* background refresh runs only once the response has been flushed to the client.
*/
/** Cold-path wait bound for a coalesced catalog rebuild (#12627). Override with CATALOG_BUILD_TIMEOUT_MS. */
export const CATALOG_BUILD_TIMEOUT_MS_DEFAULT = 8_000;
/** Defers `task` until it is safe to run without delaying the current response. */
function catalogBuildTimeoutMs(): number {
const raw = process.env.CATALOG_BUILD_TIMEOUT_MS;
if (!raw) return CATALOG_BUILD_TIMEOUT_MS_DEFAULT;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : CATALOG_BUILD_TIMEOUT_MS_DEFAULT;
}
/**
* Default scheduler (#8728 / #11551).
*
* Next's `after()` runs the task once the response has been flushed, which is the
* whole point of the stale-while-revalidate path: the builder is overwhelmingly
* synchronous under the single-threaded App Router, so running it before the flush
* pins the event loop and the "served immediately" stale body only reaches the
* client after the rebuild finishes.
*
* `after()` requires a Next request scope. Callers outside one (instrumentation
* warm-up, direct unit-test imports) fall back to a macrotask, which preserves the
* "hand the response back first" ordering within the same process.
*/
const catalogLastGood = new Map<string, CachedCatalog>();
type CatalogInFlight = {
version: number;
promise: Promise<CachedCatalog>;
};
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(label)), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err); }
);
});
}
const catalogCache = new Map<string, CachedCatalog>();
@@ -251,6 +243,7 @@ function storePayload(
if (buildGeneration === getModelCatalogCacheVersion()) {
catalogCache.set(cacheKey, entry);
}
if (entry.status === 200) catalogLastGood.set(cacheKey, entry);
return entry;
}
@@ -318,6 +311,37 @@ function runBuilder(
return buildPayload(request);
}
async function awaitCatalogInFlight(
cacheKey: string,
inflight: InFlightBuild,
corsHeaders: Record<string, string>,
diagnosticHeaders: Record<string, string>
): Promise<Response> {
let payload: CachedCatalog;
try {
payload = await withTimeout(inflight.promise, catalogBuildTimeoutMs(), "catalog_build_timeout");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (catalogInFlight.get(cacheKey)?.promise === inflight.promise) {
catalogInFlight.delete(cacheKey);
}
const lastGood = catalogLastGood.get(cacheKey);
if (msg === "catalog_build_timeout" && lastGood) {
return new Response(lastGood.body, {
status: lastGood.status,
headers: mergeCatalogHeaders(corsHeaders, lastGood.headers, diagnosticHeaders, {
"x-omniroute-catalog": "last-good",
}),
});
}
throw err;
}
return new Response(payload.body, {
status: payload.status,
headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
});
}
/**
* Resolve the cached catalog response for `request`, building it through
* `buildPayload` when there is nothing fresh to serve.
@@ -382,11 +406,7 @@ export async function resolveCachedCatalogResponse(
});
}
const payload = await inflight.promise;
return new Response(payload.body, {
status: payload.status,
headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
});
return awaitCatalogInFlight(cacheKey, inflight, corsHeaders, diagnosticHeaders);
}
// ── Test hooks ───────────────────────────────────────────────────────────────
@@ -397,6 +417,7 @@ export function __resetCatalogBuilderRunsForTest(): void {
_catalogBuilderRuns = 0;
catalogCache.clear();
catalogInFlight.clear();
catalogLastGood.clear();
lastSeenCatalogCacheVersion = getModelCatalogCacheVersion();
}

View File

@@ -21,6 +21,12 @@
* `<id>-<tier>` catalog entries (open-sse/utils/syncedEffortVariants.ts) — it
* never runs over the base entry's `capabilities`, so it cannot substitute
* for this check. Required (not optional) so no call site can silently skip it.
*
* #12299 carve-out: Kimi K3's synced base entries (`k3`, `k3-256k` — the kmca
* catalog's `low`/`high`/`max` vocabulary) are exempted from the exclusion so
* catalog-only clients (OpenCode, plain SDK pickers) can see and select their
* tiers. Model-scoped, never provider-wide: Codex, GLM, and non-K3 kimi models
* keep the full exclusion exactly as before this carve-out.
*/
// Use the same canonical alias as catalogModelPolicy.ts (l.1) — a relative path from
// src/app/api/v1/models/ to open-sse/ would need 5 `../` and silently breaks under
@@ -39,8 +45,30 @@ interface SyncedCapabilityFlags {
supportedThinkingEfforts?: string[];
}
// Model-id pattern for the Kimi K3 family (#12299): the kmca catalog syncs
// `k3`/`k3-256k` (and prefixed forms such as `kmca/k3`). Same shape the
// executor/translator layers use to recognize K3 elsewhere
// (reasoningContentInjector.ts::K3_AUTHENTIC_REASONING_PATTERN).
const KIMI_K3_MODEL_ID_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i;
/**
* #12299: only Kimi K3's synced BASE entries are exempt from the
* `isSkippedEffortProvider` exclusion. Model-scoped, never provider-wide —
* the exemption requires a kimi-owned provider AND a K3 model id, so Codex,
* GLM, and non-K3 kimi models keep the exclusion contract from #7694.
*/
function isExemptKimiK3BaseModel(sm: SyncedCapabilityFlags, ownedBy: string): boolean {
return (
ownedBy.startsWith("kimi") && typeof sm.id === "string" && KIMI_K3_MODEL_ID_PATTERN.test(sm.id)
);
}
function effectiveEffortTiers(sm: SyncedCapabilityFlags, ownedBy: string): string[] | undefined {
if (isSkippedEffortProvider(ownedBy)) return undefined;
// Exclusion gate (#7694): codex/glm/kimi own a conflicting `-{effort}` suffix
// mechanism — the blind opencode-plugin mapping must never see effort_tiers
// for them, or it double-handles the suffix. #12299 narrows only the kimi K3
// base-model entries out of that gate; everything else stays excluded.
if (isSkippedEffortProvider(ownedBy) && !isExemptKimiK3BaseModel(sm, ownedBy)) return undefined;
const learned = sm.id ? getLearnedReasoningEffortForModel(sm.id) : null;
const synced =
Array.isArray(sm.supportedThinkingEfforts) && sm.supportedThinkingEfforts.length > 0

View File

@@ -1,6 +1,10 @@
import { getProviderAlias } from "@/shared/constants/providers";
import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
import { APP_CONFIG } from "@/shared/constants/appConfig";
import {
generationDurationMs,
tokensPerSecond,
} from "@omniroute/open-sse/utils/generationThroughput";
type UsageLike = Record<string, unknown> | null | undefined;
@@ -123,6 +127,7 @@ export function buildOmniRouteResponseMetaHeaders({
requestId = null,
strategy = null,
usage = null,
ttftMs = null,
}: {
cacheHit?: boolean;
costUsd?: unknown;
@@ -145,6 +150,12 @@ export function buildOmniRouteResponseMetaHeaders({
*/
strategy?: string | null;
usage?: UsageLike;
/**
* First-token latency in ms. Required to emit tok/s: generation speed is
* `output_tokens / (latencyMs - ttftMs)` and MUST omit the field when TTFT
* is unknown so plugins do not treat `tokens / total_latency` as speed.
*/
ttftMs?: number | null;
}): Record<string, string> {
const tokens = getOmniRouteTokenCounts(usage);
const headers: Record<string, string> = {
@@ -186,6 +197,15 @@ export function buildOmniRouteResponseMetaHeaders({
headers[OMNIROUTE_RESPONSE_HEADERS.decision] = decisionValue;
}
let tps = tokensPerSecond(tokens.output, generationDurationMs(toFiniteNumber(latencyMs), ttftMs));
if (tps == null && usage && typeof usage === "object") {
const fromUsage = toFiniteNumber((usage as Record<string, unknown>).tokens_per_second);
if (fromUsage > 0) tps = fromUsage;
}
if (tps != null) {
headers[OMNIROUTE_RESPONSE_HEADERS.tokensPerSecond] = toHeaderValue(tps.toFixed(3));
}
return headers;
}

View File

@@ -2710,7 +2710,7 @@
"ccOnboardingTitle": "settings.json لاكتشاف نموذج البوابة",
"ccOnboardingCopy": "نسخ",
"ccOnboardingCopied": "تم النسخ",
"ccOnboardingKeyPlaceholder": "<مفتاح واجهة برمجة تطبيقات OmniRoute الخاص بك>",
"ccOnboardingKeyPlaceholder": "'<مفتاح واجهة برمجة تطبيقات OmniRoute الخاص بك>'",
"ccOnboardingWindowNote": "يفترض Claude Code وجود نافذة سياق تبلغ 200K لأي معرف نموذج لا يتعرف عليه. بالنسبة لنموذج له نافذة حقيقية مختلفة، أضف CLAUDE_CODE_AUTO_COMPACT_WINDOW أسفلها مباشرة حتى لا يتم تشغيل الضغط التلقائي في وقت مبكر جدًا.",
"failedSave": "فشل الحفظ",
"profileSyncTitle": "المزامنة التلقائية لملفات تعريف CLI",
@@ -6166,7 +6166,7 @@
"freeaiapikey": "بروكسي API مخفض لأكثر من 40 نموذجًا بما في ذلك GPT-5 و Claude Opus 4.6 و Claude Sonnet 4.6 و Qwen 3.5. احصل على مفتاح API الخاص بك من https://freeaiapikey.com/dashboard. عنوان URL الأساسي: https://freeaiapikey.com/v1.",
"freemodel-dev": "احصل على رصيد API مجاني بقيمة 300 دولار على https://freemodel.dev — لا يلزم إدخال معلومات الدفع. نقطة نهاية متوافقة مع OpenAI. تتوفر نماذج GPT-5.4 و GPT-5.5.",
"friendliai": "فئة مجانية للاستدلال بدون خادم — لا يلزم وجود بطاقة ائتمان",
"gemini": "مجاني للأبد: 1,500 طلب/يوم لـ Gemini 2.5 Flash — بدون بطاقة ائتمان، احصل على المفتاح من aistudio.google.com",
"gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.",
"gigachat": "ربط GigaChat (Sber) بمفتاح API.",
"gitlab": "رمز وصول شخصي لـ GitLab لواجهة برمجة تطبيقات مقترحات الأكواد العامة. قم بتكوين عنوان URL أساسي مستضاف ذاتيًا عند عدم استخدام gitlab.com.",
"gitlawb-gmi": "احصل على مفتاح API الخاص بك من لوحة تحكم Gitlawb Opengateway.",
@@ -6175,7 +6175,7 @@
"glm-cn": "ربط GLM Coding (الصين) بمفتاح API.",
"glmt": "ملف تعريف GLM مسبق الضبط بميزانية رموز أعلى، وتمكين التفكير، ومهلة أطول.",
"getgoapi": "ربط GoAPI بمفتاح API.",
"groq": "الفئة المجانية: 30 طلبًا في الدقيقة / 14.4 ألف طلب في اليوم — بدون بطاقة ائتمان",
"groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.",
"haiper": "احصل على مفتاح API من haiper.ai/haiper-api",
"heroku": "ربط Heroku AI بمفتاح API.",
"hcnsec": "احصل على مفتاح API من api.hcnsec.cn",
@@ -12982,7 +12982,7 @@
"description": "بعد مزامنة المزود والنموذج، أعد إنشاء ملفات التعريف ~/.codex/*.config.toml من الكتالوج المباشر. لا يغير هذا أبداً تكوين Codex النشط أو الافتراضي ويكون معطلاً بشكل افتراضي."
},
"OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
"description": "بعد مزامنة المزود والنموذج، أعد إنشاء ملفات التعريف ~/.claude/profiles/<name>/settings.json من الكتالوج المباشر. لا يغير هذا أبداً تكوين Claude النشط أو الافتراضي ويكون معطلاً بشكل افتراضي."
"description": "بعد مزامنة المزود والنموذج، أعد إنشاء ملفات التعريف ~/.claude/profiles/'<name>'/settings.json من الكتالوج المباشر. لا يغير هذا أبداً تكوين Claude النشط أو الافتراضي ويكون معطلاً بشكل افتراضي."
},
"OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
"description": "تعطيل نقطة نهاية فحص صحة المثيل المحلي."
@@ -13133,6 +13133,7 @@
"segmentHint": "كل شريحة = مجمع مجاني واحد · مجمع مفرود من التكرار، عدّ صادق (بدون حدود قصوى مضخمة لمعدل الطلبات).",
"boost": "افتح حوالي {tokens} إضافية/شهرياً بشحن رصيد OpenRouter لمرة واحدة بقيمة 10$ (50 ← 1000 طلب/يوم)",
"uncapped": "مجاني بشكل دائم، بدون حد أقصى معلن (محدود بمعدل الطلبات) — وصول حقيقي، لا يُحتسب في العنوان الرئيسي:",
"gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:",
"tosRestricted": "{count, plural, one {# نموذج تم وضع علامة عليه كمقيد بشروط الخدمة} other {# نماذج تم وضع علامة عليها كمقيدة بشروط الخدمة}} — القرار لك",
"provider": "المزود",
"model": "النموذج",

View File

@@ -2710,7 +2710,7 @@
"ccOnboardingTitle": "gateway modeli kəşfi üçün settings.json",
"ccOnboardingCopy": "Kopyala",
"ccOnboardingCopied": "Kopyalandı",
"ccOnboardingKeyPlaceholder": "<your OmniRoute API açarınız>",
"ccOnboardingKeyPlaceholder": "'<your OmniRoute API açarınız>'",
"ccOnboardingWindowNote": "Claude Code tanımadığı hər hansı model id üçün 200K kontekst pəncərəsi qəbul edir. Fərqli real pəncərəyə malik bir model üçün, avtomatik sıxılmanın çox tez başlamaması üçün onun altına CLAUDE_CODE_AUTO_COMPACT_WINDOW əlavə edin.",
"failedSave": "Yadda saxlamaq mümkün olmadı",
"profileSyncTitle": "CLI profilinin avtomatik sinxronizasiyası",
@@ -6166,7 +6166,7 @@
"freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 daxil olmaqla 40-dan çox model üçün endirimli API proksisi. API açarınızı https://freeaiapikey.com/dashboard ünvanından əldə edin. Baza URL: https://freeaiapikey.com/v1.",
"freemodel-dev": "https://freemodel.dev ünvanında $300 pulsuz API krediti əldə edin — ödəniş məlumatı tələb olunmur. OpenAI ilə uyğun son nöqtə. GPT-5.4 və GPT-5.5 modelləri mövcuddur.",
"friendliai": "Serverless çıxarış üçün pulsuz tarif — kredit kartı tələb olunmur",
"gemini": "Həmişə pulsuz: Gemini 2.5 Flash üçün gündə 1,500 sorğu — kredit kartı yoxdur, açarı aistudio.google.com ünvanından əldə edin",
"gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.",
"gigachat": "GigaChat (Sber)-ı API açarı ilə qoşun.",
"gitlab": "İctimai Code Suggestions API üçün GitLab şəxsi giriş tokeni. gitlab.com istifadə etmədikdə, self-hosted baza URL-i konfiqurasiya edin.",
"gitlawb-gmi": "API açarınızı Gitlawb Opengateway idarəetmə panelindən əldə edin.",
@@ -6175,7 +6175,7 @@
"glm-cn": "GLM Coding (China)-i API açarı ilə qoşun.",
"glmt": "Daha yüksək token büdcəsi, düşünmə aktivləşdirilmiş və daha uzun vaxt aşımı olan hazır GLM profili.",
"getgoapi": "GoAPI-ni API açarı ilə qoşun.",
"groq": "Pulsuz tarif: 30 RPM / 14.4K RPD — kredit kartı tələb olunmur",
"groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD)no payment method on file.",
"haiper": "API açarını haiper.ai/haiper-api ünvanından əldə edin",
"heroku": "Heroku AI-ı API açarı ilə qoşun.",
"hcnsec": "API açarını api.hcnsec.cn ünvanından əldə edin",
@@ -12982,7 +12982,7 @@
"description": "Provayder-model sinxronizasiyasından sonra canlı kataloqdan ~/.codex/*.config.toml profillərini yenidən yaradın. Bu, heç vaxt aktiv və ya defolt Codex konfiqurasiyasını dəyişmir və defolt olaraq qapalıdır."
},
"OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
"description": "Provayder-model sinxronizasiyasından sonra canlı kataloqdan ~/.claude/profiles/<name>/settings.json profillərini yenidən yaradın. Bu, heç vaxt aktiv və ya defolt Claude konfiqurasiyasını dəyişmir və defolt olaraq qapalıdır."
"description": "Provayder-model sinxronizasiyasından sonra canlı kataloqdan ~/.claude/profiles/'<name>'/settings.json profillərini yenidən yaradın. Bu, heç vaxt aktiv və ya defolt Claude konfiqurasiyasını dəyişmir və defolt olaraq qapalıdır."
},
"OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
"description": "Yerli instansiyanın sağlamlıq yoxlaması son nöqtəsini sıradan çıxarın."
@@ -13133,6 +13133,7 @@
"segmentHint": "Hər seqment = bir pulsuz hovuz · hovuz üzrə təkrarlanmayan, dürüst sayım (şişirdilmiş sorğu limiti tavanları olmadan).",
"boost": "Birdəfəlik $10 OpenRouter balans artımı ilə ayda ~{tokens} daha çox əldə edin (50 → 1000 sorğu/gün)",
"uncapped": "Həmişəlik pulsuz, dərc edilmiş limit yoxdur (sorğu sayı məhdudlaşdırılıb) — real giriş, əsas göstəricidə sayılmır:",
"gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:",
"tosRestricted": "{count, plural, one {# model} other {# model}} ToS ilə məhdudlaşdırılmış kimi qeyd edilib — qərar sizindir",
"provider": "Provayder",
"model": "Model",

View File

@@ -2710,7 +2710,7 @@
"ccOnboardingTitle": "settings.json за откриване на модел на шлюз",
"ccOnboardingCopy": "Копирай",
"ccOnboardingCopied": "Копирано",
"ccOnboardingKeyPlaceholder": "<вашият ключ за OmniRoute API>",
"ccOnboardingKeyPlaceholder": "'<вашият ключ за OmniRoute API>'",
"ccOnboardingWindowNote": "Claude Code предполагае контекстен прозорец от 200K за всяко идентификатор на модел, който не разпознава. За модел с различен реален прозорец, добавете CLAUDE_CODE_AUTO_COMPACT_WINDOW точно под него, за да не се задейства автоматичното компресиране твърде рано.",
"failedSave": "Неуспешно запазване",
"profileSyncTitle": "Автоматично синхронизиране на CLI профили",
@@ -6166,7 +6166,7 @@
"freeaiapikey": "API прокси с отстъпка за над 40 модела, включително GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Вземете своя API ключ на https://freeaiapikey.com/dashboard. Базов URL адрес: https://freeaiapikey.com/v1.",
"freemodel-dev": "Вземете $300 безплатни API кредити на https://freemodel.dev — не се изисква информация за плащане. Съвместима с OpenAI крайна точка. Налични са модели GPT-5.4 и GPT-5.5.",
"friendliai": "Безплатен план за serverless inference — не се изисква кредитна карта",
"gemini": "Безплатно завинаги: 1,500 заявки/ден за Gemini 2.5 Flash — без кредитна карта, вземете ключ на aistudio.google.com",
"gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.",
"gigachat": "Свържете GigaChat (Sber) с API ключ.",
"gitlab": "Личен токен за достъп на GitLab за публичния Code Suggestions API. Конфигурирайте self-hosted базов URL адрес, когато не използвате gitlab.com.",
"gitlawb-gmi": "Вземете своя API ключ от таблото за управление на Gitlawb Opengateway.",
@@ -6175,7 +6175,7 @@
"glm-cn": "Свържете GLM Coding (China) с API ключ.",
"glmt": "Предварително зададен GLM профил с по-висок бюджет за токени, активирано мислене и по-дълъг таймаут.",
"getgoapi": "Свържете GoAPI с API ключ.",
"groq": "Безплатен план: 30 RPM / 14.4K RPD — без кредитна карта",
"groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD)no payment method on file.",
"haiper": "Вземете API ключ на haiper.ai/haiper-api",
"heroku": "Свържете Heroku AI с API ключ.",
"hcnsec": "Вземете API ключ на api.hcnsec.cn",
@@ -12982,7 +12982,7 @@
"description": "След синхронизиране на доставчик-модел, регенериране на ~/.codex/*.config.toml профили от каталога на живо. Това никога не променя активната или подразбиращата се конфигурация на Codex и е изключено по подразбиране."
},
"OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
"description": "After provider-model synchronization, regenerate ~/.claude/profiles/<name>/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default."
"description": "After provider-model synchronization, regenerate ~/.claude/profiles/'<name>'/settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default."
},
"OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
"description": "Деактивиране на крайната точка за проверка на състоянието на локалния екземпляр."
@@ -13133,6 +13133,7 @@
"segmentHint": "Всеки сегмент = един безплатен пул · дедупликиран пул, честно отчитане (без изкуствено завишени тавани на лимитите за скорост).",
"boost": "Отключете още ~{tokens}/месец с еднократно допълване от $10 в OpenRouter (50 → 1000 заявки/ден)",
"uncapped": "Постоянно безплатно, без публикуван лимит (с ограничение на скоростта) — реален достъп, който не се отчита в заглавието:",
"gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:",
"tosRestricted": "{count, plural, one {# модел е маркиран като ограничен от Условията за ползване — вие решавате} other {# модела са маркирани като ограничени от Условията за ползване — вие решавате}}",
"provider": "Доставчик",
"model": "Модел",

View File

@@ -2710,7 +2710,7 @@
"ccOnboardingTitle": "gateway মডেল আবিষ্কারের জন্য settings.json",
"ccOnboardingCopy": "কপি করুন",
"ccOnboardingCopied": "কপি করা হয়েছে",
"ccOnboardingKeyPlaceholder": "<আপনার OmniRoute API কী>",
"ccOnboardingKeyPlaceholder": "'<আপনার OmniRoute API কী>'",
"ccOnboardingWindowNote": "Claude Code একটি 200K প্রসঙ্গ উইন্ডো ধারণ করে যেকোন মডেল আইডির জন্য যা এটি চিনতে পারে না। একটি ভিন্ন বাস্তব উইন্ডো সহ মডেলের জন্য, এর ঠিক নিচে CLAUDE_CODE_AUTO_COMPACT_WINDOW যোগ করুন যাতে স্বয়ংক্রিয় সংকোচন খুব তাড়াতাড়ি শুরু না হয়।",
"failedSave": "সংরক্ষণ করতে ব্যর্থ হয়েছে",
"profileSyncTitle": "CLI প্রোফাইল অটো-সিঙ্ক",
@@ -6166,7 +6166,7 @@
"freeaiapikey": "GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5 সহ 40+ মডেলের জন্য ডিসকাউন্টেড API প্রক্সি। https://freeaiapikey.com/dashboard থেকে আপনার API কী পান। বেস URL: https://freeaiapikey.com/v1।",
"freemodel-dev": "https://freemodel.dev থেকে $300 ফ্রি API ক্রেডিট পান — কোনো পেমেন্ট তথ্যের প্রয়োজন নেই। OpenAI-সামঞ্জস্যপূর্ণ এন্ডপয়েন্ট। GPT-5.4 and GPT-5.5 মডেলগুলো উপলব্ধ।",
"friendliai": "সার্ভারলেস ইনফারেন্সের জন্য ফ্রি টিয়ার — কোনো ক্রেডিট কার্ডের প্রয়োজন নেই",
"gemini": "চিরকালের জন্য ফ্রি: Gemini 2.5 Flash-এর জন্য প্রতিদিন 1,500টি রিকোয়েস্ট — কোনো ক্রেডিট কার্ড লাগবে না, aistudio.google.com থেকে কী পান",
"gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.",
"gigachat": "একটি API কী দিয়ে GigaChat (Sber) কানেক্ট করুন।",
"gitlab": "পাবলিক Code Suggestions API-এর জন্য GitLab পার্সোনাল অ্যাক্সেস টোকেন। gitlab.com ব্যবহার না করার সময় একটি সেলফ-হোস্টেড বেস URL কনফিগার করুন।",
"gitlawb-gmi": "Gitlawb Opengateway ড্যাশবোর্ড থেকে আপনার API কী পান।",
@@ -6175,7 +6175,7 @@
"glm-cn": "একটি API কী দিয়ে GLM Coding (China) কানেক্ট করুন।",
"glmt": "উচ্চতর টোকেন বাজেট, থিংকিং সক্রিয় এবং দীর্ঘতর টাইমআউট সহ প্রিসেট GLM প্রোফাইল।",
"getgoapi": "একটি API কী দিয়ে GoAPI কানেক্ট করুন।",
"groq": "ফ্রি টিয়ার: 30 RPM / 14.4K RPD — কোনো ক্রেডিট কার্ড লাগবে না",
"groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD)no payment method on file.",
"haiper": "haiper.ai/haiper-api থেকে API কী পান",
"heroku": "একটি API কী দিয়ে Heroku AI কানেক্ট করুন।",
"hcnsec": "api.hcnsec.cn-এ API কী পান",
@@ -12982,7 +12982,7 @@
"description": "প্রোভাইডার-মডেল সিঙ্ক্রোনাইজেশনের পরে, লাইভ ক্যাটালগ থেকে ~/.codex/*.config.toml প্রোফাইলগুলি পুনরায় তৈরি করুন। এটি সক্রিয় বা ডিফল্ট Codex কনফিগারেশন কখনই পরিবর্তন করে না এবং ডিফল্টভাবে বন্ধ থাকে।"
},
"OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
"description": "প্রোভাইডার-মডেল সিঙ্ক্রোনাইজেশনের পরে, লাইভ ক্যাটালগ থেকে ~/.claude/profiles/<name>/settings.json প্রোফাইলগুলি পুনরায় তৈরি করুন। এটি সক্রিয় বা ডিফল্ট Claude কনফিগারেশন কখনই পরিবর্তন করে না এবং ডিফল্টভাবে বন্ধ থাকে।"
"description": "প্রোভাইডার-মডেল সিঙ্ক্রোনাইজেশনের পরে, লাইভ ক্যাটালগ থেকে ~/.claude/profiles/'<name>'/settings.json প্রোফাইলগুলি পুনরায় তৈরি করুন। এটি সক্রিয় বা ডিফল্ট Claude কনফিগারেশন কখনই পরিবর্তন করে না এবং ডিফল্টভাবে বন্ধ থাকে।"
},
"OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
"description": "স্থানীয় ইনস্ট্যান্স হেলথ-চেক এন্ডপয়েন্ট নিষ্ক্রিয় করুন।"
@@ -13133,6 +13133,7 @@
"segmentHint": "প্রতিটি সেগমেন্ট = একটি ফ্রি পুল · পুল-ডিডুপ্লিকেটেড, সঠিক গণনা (কোনো অতিরঞ্জিত রেট-লিমিট সিলিং নেই)।",
"boost": "এককালীন $10 OpenRouter টপ-আপের মাধ্যমে প্রতি মাসে আরও ~{tokens} আনলক করুন (50 → 1000 req/day)",
"uncapped": "স্থায়ীভাবে বিনামূল্যে, কোনো প্রকাশিত সীমা নেই (রেট-সীমিত) — প্রকৃত অ্যাক্সেস, হেডলাইনে গণনা করা হয়নি:",
"gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:",
"tosRestricted": "{count, plural, one {#টি মডেল} other {#টি মডেল}} ToS-সীমাবদ্ধ হিসেবে চিহ্নিত — সিদ্ধান্ত আপনার",
"provider": "প্রোভাইডার",
"model": "মডেল",

View File

@@ -2710,7 +2710,7 @@
"ccOnboardingTitle": "settings.json pro objevování modelu brány",
"ccOnboardingCopy": "Kopírovat",
"ccOnboardingCopied": "Zkopírováno",
"ccOnboardingKeyPlaceholder": "<váš klíč API OmniRoute>",
"ccOnboardingKeyPlaceholder": "'<váš klíč API OmniRoute>'",
"ccOnboardingWindowNote": "Claude Code předpokládá kontextové okno 200K pro jakýkoli model id, který nepozná. Pro model s jiným skutečným oknem přidejte CLAUDE_CODE_AUTO_COMPACT_WINDOW těsně pod něj, aby automatická komprese nenastala příliš brzy.",
"failedSave": "Nepodařilo se uložit",
"profileSyncTitle": "Automatická synchronizace profilů CLI",
@@ -6166,7 +6166,7 @@
"freeaiapikey": "Zlevněná API proxy pro více než 40 modelů včetně GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Získejte svůj API klíč na https://freeaiapikey.com/dashboard. Základní URL: https://freeaiapikey.com/v1.",
"freemodel-dev": "Získejte bezplatný API kredit 300 $ na https://freemodel.dev nejsou vyžadovány žádné platební údaje. Koncový bod kompatibilní s OpenAI. K dispozici jsou modely GPT-5.4 a GPT-5.5.",
"friendliai": "Bezplatná úroveň pro serverless inferenci není vyžadována platební karta",
"gemini": "Navždy zdarma: 1 500 požadavků/den pro Gemini 2.5 Flash bez platební karty, klíč získáte na aistudio.google.com",
"gemini": "__MISSING__:Free tier through Google AI Studio; per-model quotas are no longer published (check the quota page in AI Studio) — no credit card.",
"gigachat": "Připojte GigaChat (Sber) pomocí API klíče.",
"gitlab": "Osobní přístupový token (PAT) GitLab pro veřejné rozhraní API Code Suggestions. Pokud nepoužíváte gitlab.com, nakonfigurujte vlastní základní URL.",
"gitlawb-gmi": "Získejte svůj API klíč z nástěnky Gitlawb Opengateway.",
@@ -6175,7 +6175,7 @@
"glm-cn": "Připojte GLM Coding (Čína) pomocí API klíče.",
"glmt": "Přednastavený profil GLM s vyšším rozpočtem tokenů, povoleným přemýšlením a delším časovým limitem.",
"getgoapi": "Připojte GoAPI pomocí API klíče.",
"groq": "Bezplatná úroveň: 30 RPM / 14,4K RPD bez platební karty",
"groq": "__MISSING__:Free plan: per-model caps (200K tokens/day per chat model; see console.groq.com/docs/rate-limits for RPM/RPD) — no payment method on file.",
"haiper": "Získejte API klíč na haiper.ai/haiper-api",
"heroku": "Připojte Heroku AI pomocí API klíče.",
"hcnsec": "Získejte API klíč na api.hcnsec.cn",
@@ -12982,7 +12982,7 @@
"description": "Po synchronizaci poskytovatelů a modelů regenerovat profily ~/.codex/*.config.toml z živého katalogu. Toto nikdy nemění aktivní nebo výchozí konfiguraci Codexu a je ve výchozím nastavení vypnuto."
},
"OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
"description": "Po synchronizaci poskytovatelů a modelů regenerovat profily ~/.claude/profiles/<name>/settings.json z živého katalogu. Toto nikdy nemění aktivní nebo výchozí konfiguraci Claude a je ve výchozím nastavení vypnuto."
"description": "Po synchronizaci poskytovatelů a modelů regenerovat profily ~/.claude/profiles/'<name>'/settings.json z živého katalogu. Toto nikdy nemění aktivní nebo výchozí konfiguraci Claude a je ve výchozím nastavení vypnuto."
},
"OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
"description": "Zakázat koncový bod kontroly stavu lokální instance."
@@ -13133,6 +13133,7 @@
"segmentHint": "Každý segment = jeden bezplatný pool · deduplikovaný pool, poctivé počítání (žádné uměle navýšené stropy limitů).",
"boost": "Odemkněte o ~{tokens} více/měs. jednorázovým dobitím $10 na OpenRouteru (50 → 1000 požadavků/den)",
"uncapped": "Trvale zdarma, bez zveřejněného limitu (omezená rychlost) — reálný přístup, nepočítá se do hlavního přehledu:",
"gated": "__MISSING__:~{tokens}/mo more behind a regional identity check (e.g. mainland-China real-name verification) — real quota, not counted in the headline:",
"tosRestricted": "{count, plural, one {# model označený jako omezený ToS} few {# modely označené jako omezené ToS} other {# modelů označených jako omezené ToS}} — rozhodnutí je na vás",
"provider": "Poskytovatel",
"model": "Model",

Some files were not shown because too many files have changed in this diff Show More