Commit Graph

3537 Commits

Author SHA1 Message Date
Goni Sulaiman
21d0e81332 fix(security): enforce allowedEndpoints on the alias rewrites (#13685) (#13741)
Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-18 11:32:37 -03:00
Sean Ford
010250cf08 fix(memory): list provider-node models in the Embedding and Rerank selectors (#13740)
* fix(api): type compatible-provider-node models in /v1/models by the node's apiType

Model rows discovered from an OpenAI-compatible provider node rarely carry
endpoint metadata — a TEI / Infinity / vLLM `/v1/models` listing is just ids —
and the catalog defaulted such rows to `["chat"]`. An `embeddings`-typed node
exposing `bge-m3` and a `rerank`-typed node exposing `bge-reranker-v2-m3`
therefore both surfaced in GET /v1/models as untyped chat models: clients
that build their picker from `type: "embedding"` / `type: "rerank"` never saw
them, and chat pickers listed models that 400 on chat.

- src/shared/constants/modelSupportedEndpoints.ts: add
  defaultEndpointsForProviderNodeApiType(apiType) — embeddings → ["embeddings"],
  rerank → ["rerank"], audio-* → themselves, images-generations → ["images"],
  chat/responses/unknown → ["chat"] (unchanged default).
- src/app/api/v1/models/catalog.ts: build a node-id → apiType map next to the
  existing node-id → type map; the synced-model and custom-model loops fall
  back to the node's modality instead of ["chat"] when a row has no
  supportedEndpoints; the custom-overlay merge path also classifies
  `type`/`subtype` from the overlay's explicit supportedEndpoints, so a manual
  `["rerank"]` row layered on a discovered chat-default row is re-typed.

Explicit supportedEndpoints on any row still take precedence, and chat /
responses nodes keep the historical behavior.

tests/unit/catalog-provider-node-apitype-endpoints.test.ts covers the helper
and the catalog end-to-end for embeddings, rerank, mixed, chat, and overlay
cases via getUnifiedModelsResponse().

* chore(changelog): name the #13734 fragment

* refactor(api): keep the provider-node modality helpers out of catalog.ts

catalog.ts is frozen by the file-size gate (must not grow past 2075
lines) and the apiType fallback pushed it to 2093. Move the node
apiType index, the endpoint fallback and the overlay type/subtype
fields into catalogNodeModality.ts so catalog.ts ends one line
shorter than the base; behaviour and tests are unchanged.

* fix(api): give nodeModelEndpoints a string[] return so the catalog classifier typechecks

The API-route typecheck gate flagged TS2345 at both classifyModelSupportedEndpoints()
call sites: the helper returned `ModelSupportedEndpoint[] | unknown[]`, and unknown[]
is not a readonly string[]. The base code only passed because the synced row's
supportedEndpoints was untyped. Same pass-through cast overlayEndpoints() already uses;
no behaviour change.

* fix(memory): list provider-node models in the embedding and rerank selectors

GET /api/memory/embedding-providers and GET /api/memory/rerank-providers
appended local provider nodes by apiType alone and always with models: [].
A node typed "embeddings" that also serves a rerank model — one TEI /
Infinity / vLLM box hosting both bge-m3 and bge-reranker-v2-m3 is the
common self-hosted layout — was filtered out of the Rerank selector
entirely (apiType not in chat/responses/rerank) and showed up in the
Embedding selector as a provider with nothing to pick. Typing prefix/model
by hand worked because the request path resolves it directly; only the
convenience layer was blind.

Add src/lib/memory/embedding/nodeModalityListings.ts, which builds the
listing from the node's synced + custom model rows, typing each row the way
/v1/models does (explicit supportedEndpoints wins, otherwise the node's
apiType via defaultEndpointsForProviderNodeApiType; a custom overlay
re-types a discovered row). A node is listed for a modality when its
apiType matches, when it is a generic chat/responses node (historical
behaviour, kept so catalog-less nodes still appear), or when any of its
rows is typed for the modality. Both endpoints use it; the curated
registries stay first and win on prefix collisions.

* chore(changelog): name the #13740 fragment
2026-09-18 11:32:26 -03:00
opensource-elearning
65263a4fe9 fix(cli): Codex long-session turn-pin fallback + codex-settings key resolution (#13564 #13563) (#13566)
* fix(sse): release native Codex turn pin when pinned model is model-scoped unusable (#13564)

Long-running Codex sessions die with 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE whenever
the model pinned to the current turn becomes model-scoped unusable mid-session (per-model
quota lockout, connection cooldown, exhausted accounts). Claude Code has no equivalent
pin and already falls back to the next healthy combo model; Codex now matches.

Release the turn pin when all pinned provider+model targets are model-scoped unusable
and fall through to full combo routing, re-pinning to whichever model succeeds. Preserve
the pin on provider-wide outages (circuit breaker OPEN, provider cooldown) and when the
pinned target is still healthy.

Also prunes the stale ESLint suppression entry for combo.ts that this change orphaned
(createPinnedModelUnavailableResponse import dropped; pre-existing getBootstrapLatencyMs
remains the sole residual unused var).

* fix(api): resolve codex-settings apiKey via canonical resolver instead of 400 (#13563)

Applying Codex settings from /dashboard/cli-code/codex always failed with
400 "baseUrl, apiKey and model are required" when the dashboard sent an empty
apiKey (cloud mode with no management key selected) — baseUrl and model are
already Zod-gated, so that response could only ever fire on the empty key.

The codex-settings route had diverged from the sibling CLI tools (cline/forge/
openclaw/grok-build/jcode): an inline if(!apiKey) 400 guard plus a hand-rolled
getApiKeyById lookup, instead of the shared resolveApiKey(keyId, apiKey) helper
which resolves by keyId, falls back to the submitted apiKey, then to
sk_omniroute. This change makes codex-settings use the canonical resolver, so:

- empty apiKey + valid keyId -> the real DB key is written to auth.json
- empty apiKey + no keyId   -> sk_omniroute default (config still applies)
- explicit apiKey           -> written verbatim (unchanged)

* docs(changelog): add fragments for Codex turn-pin fallback and codex-settings apiKey resolution
2026-09-18 11:31:52 -03:00
小妍儿 ✨
6d585625f0 fix(providers): honor Alibaba workspace embedding and rerank endpoints (#13293)
* fix(providers): honor Alibaba workspace embedding and rerank endpoints

* docs(changelog): add Alibaba workspace endpoint fix

* refactor(rerank): keep Alibaba response adapter focused

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
2026-09-18 11:30:53 -03:00
Wu Shuwen
36493a6270 fix(evals): fail a case whose model call errored instead of scoring it passed (#13201)
* fix(evals): fail a case whose model call errored instead of scoring it passed

runSuite() attached caseMetrics[id].error to the graded result but never forced
`passed` to false. executeEvalCase() returns a failed call as an ordinary output
string ("[ERROR] <message>"), so any expected pattern that happened to match that
text was recorded as a pass. That inflates the reported pass rate, and reports a
non-zero score for a run in which no model was ever reached.

Built-in codex-comparison case codex-07 reproduces it: its pattern is
"try|catch|throw|error|Error" and the provider-resolution failure text ends with
"...added as a combo entry.", so the `try` alternative matches and the case is
scored as passed while carrying a non-empty error.

A case that never reached a model has no measured behaviour to grade, so a
failure is the only honest score.

Refs #13137

* docs(changelog): add fragment for the errored-eval-case fix (#13201)
2026-09-18 11:30:35 -03:00
Tony Yu
3d3f71f514 fix(oauth): read pollToken body once on non-JSON upstream responses (kimi-coding, github) (#13046)
* fix(oauth): read pollToken body once on non-JSON upstream responses

The device-flow pollToken handlers for kimi-coding and github tried
response.json() first and fell back to response.text() in the catch.
Once .json() rejects on a non-JSON body the stream is already consumed,
so the .text() fallback always throws TypeError (Body is unusable) and
pollToken rejects, surfacing as a generic 500 on /api/oauth/<provider>/poll
instead of the intended graceful { error: "invalid_response" } payload.
Non-JSON responses are realistic when the OAuth upstream sits behind a
CDN/anti-bot HTML error page or a proxy interstitial (auth.kimi.com in
particular).

Read the body once as text, then JSON.parse it, preserving the original
invalid_response fallback. Adds a regression test that drives both
providers with a stubbed fetch returning an HTML error page and a JSON
error body. Prunes the two now-unused no-unused-vars suppressions for the
removed catch bindings.

* docs(changelog): fragment for #13046
2026-09-18 11:30:09 -03:00
ZaimMarzuki
bb198df737 fix(analytics): resolve account email/name in Utilization chart and fix tooltip stacking context (#13029)
Co-authored-by: ZaimMarzuki <ZaimMarzuki@users.noreply.github.com>
2026-09-18 11:30:01 -03:00
Goni Sulaiman
0551893390 fix(dashboard): expose the Modal Base URL field in the connection modals (#12704) (#12736)
Modal is bring-your-own-deploy, so every connection needs its own app URL, and
the server-side validator already required providerSpecificData.baseUrl. The
add/edit connection form never rendered the field, so a Modal connection could
not be validated or saved at all.

Adding the id to CONFIGURABLE_BASE_URL_PROVIDERS reuses the same always-on Base
URL field as the kimi/moonshot case (#7447). The placeholder switch is folded
into a record lookup in the same commit so the function stays under the
complexity cap as ids are added; the record was checked against the switch for
every pre-existing id and only "modal" changes behaviour.

Rebased onto the current release tip, which now carries #13120's own entry in
the same set.

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-18 11:29:08 -03:00
Mike
82b02f6054 fix(cli): detect npm-global Claude .cmd shims under Program Files on Windows (#12565)
* fix(cli): find npm-global Claude .cmd shims under Program Files\nodejs (#12563)

Stock Node MSI installs drop claude.cmd there, but detection never listed that directory and Electron's PATH often omits it, so the dashboard reported settings_found_binary_unresolved while a normal shell could run the CLI.

* fix(cli): soft-fail npm prefix cache and enrich Windows lookup PATH (#12563)

Stop permanently caching a failed npm config get prefix as empty, and prepend npm-prefix / APPDATA\npm / nvm / Program Files dirs on Windows lookup PATH so custom installs survive Electron PATH gaps.
2026-09-18 11:28:52 -03:00
Kizuno18
6d0dc5a50c fix(combo): scope Claude model failures to the model, not the account (#12340)
* fix(combo): scope Claude model failures to the model, not the account

A priority combo whose steps are five models on one Claude OAuth connection
stops at step 1. hasPerModelQuota() returns false for the claude provider, so
markAccountUnavailable() records a model-specific 404 or 5xx against the
connection row, and getPersistedConnectionCooldownSkipReason() then skips every
sibling step before dispatch. Verified with a combo whose first step names a
model that cannot exist: the 404 lands and step 2 is never tried, while the
same combo works as soon as step 2 points at a different account.

A Claude OAuth connection multiplexes Fable 5, Opus 5/4.8/4.7/4.6, Sonnet and
Haiku behind one credential, which is the multiplexing hasPerModelQuota already
describes. Its quota is a separate question: a 429 on a Max subscription is
account-wide, and shouldMarkAccountExhaustedFrom429 pins that. So this adds
hasPerModelFailureScope() for the non-quota statuses and leaves 429 alone.

Combo exhaustion gets the same treatment for 404, which names one model the
account cannot serve rather than a bad connection. The empty-content 502 was
already exempt through isEmptyContentFailure.

Closes #12334

* fix(combo): extract Claude per-model failure scope to keep file-size caps

Move hasPerModelFailureScope into a leaf so frozen accountFallback.ts and
auth.ts stay at their file-size caps after #12334. Register
combo-claude-per-model-scope.test.ts in Stryker tap.testFiles so the
mutation-test-coverage gate sees the covering unit test.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:28:20 -03:00
brick30llc-ctrl
3a5f37013d fix(sse): classify "N API calls / month" 429 text as quota_exhausted (#12252)
* fix(sse): classify "N API calls / month" 429 text as quota_exhausted

QUOTA_PATTERNS recognized "monthly ... limit" and "per month ... limit",
but not the reversed order providers actually use in trial-key messages —
Cohere's is "You are using a Trial key, which is limited to 1000 API calls
/ month". That 429 fell through to the generic short-backoff rate_limit
path, so a monthly allowance that will not reset for the rest of the
billing cycle kept being retried every few seconds.

Broaden the pattern set to catch both phrase orders and the "N calls /
month" and "N requests/month" spellings.

Observed in production against Cohere trial keys: 481 of 752 429s in 24h
carried this text and were all treated as short transient rate limits.

* docs(changelog): add fragment for #12252

---------

Co-authored-by: brick30llc-ctrl <admin@brick30.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:28:03 -03:00
Bob.Hou
815dedd3c9 fix(resilience): extend process crash guard to combo hedge cancels and upstream fetch failures (#13636) (#14064)
Closes a real process-killer: the direct-response start timeout could fire after the fetch promise had already settled, and aborting at that point delivered the abort reason to a promise nobody was awaiting — Node promotes that to an `unhandledRejection` → `uncaughtException` and the process dies (#12861). The timer is now a no-op once the attempt has settled, and the same guard is extended to combo hedge cancels and upstream fetch failures.

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:32 -03:00
Bob.Hou
9956f13b35 fix(db): never TRUNCATE-checkpoint a live WAL (SIGBUS under traffic) (#14005)
* fix(db): never TRUNCATE-checkpoint a live WAL

A live TRUNCATE checkpoint rewrites the shared wal-index while other
processes hold it mapped; dereferencing the stale mapping SIGBUSes the
process. Two production crashes six hours apart, coredump stack in
better-sqlite3 native memcpy (issue #13973).

Remove the periodic TRUNCATE scheduler. Runtime checkpoints are
PASSIVE-only, which move pages without changing the wal-index geometry,
while TRUNCATE stays on the shutdown path where reclaiming the file is
safe. The 256MB size guard now warns instead of escalating to a live
TRUNCATE, busy PASSIVE ticks feed the persisted busy telemetry that the
TRUNCATE tick used to carry, and a positive
OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS logs a one-time deprecation warning.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(db): RESTART the WAL when it exceeds the size guard

The 256MB size guard only warned, so a live WAL could keep growing until
the next restart. wal_checkpoint(RESTART) starts a new WAL file without
rewriting the mapped wal-index, which is what SIGBUS'd the process when
we used TRUNCATE under traffic.

Related to #13973.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* docs: drop a fake TRUNCATE env name from the WAL guard row

Backticks around TRUNCATE made the env/docs checker treat it as a
variable. The VACUUM rows next to it were never part of this change
and are not in the base docs.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-18 10:21:03 -03:00
Diego Rodrigues de Sa e Souza
24fc202d9f fix(ci): repair the API Route Typecheck base-red blocking every PR (#14079)
The gate fails on the pure release/v3.8.51 tip (3 files above the frozen
baseline), so every PR against the release is born red on it. None of the
three is a PR defect — they are drift from merged work:

- src/lib/usage/glmResetCards.ts: entered the gate's scope when #12754 added
  the route that imports it. runWithProxyContext is an untyped async helper
  (Promise<any>), so runWithConnectionFetch<T> could not return T. Every call
  site passes an async callback and awaits it — declare that contract:
  fn: () => Promise<T> → Promise<T>.
- src/sse/handlers/chat.ts: handleSingleModelChat had no return annotation, so
  runWithTransientBackendRetry<T extends ResponseLike> fell back to the
  constraint and the value could no longer feed withSessionHeader(Response).
  Annotated Promise<Response> (every return path builds a Response).
- src/app/api/internal/codex-responses-ws/route.ts: the bridge helpers return
  either { error: Response } or a payload, but as unannotated object-literal
  unions TypeScript synthesised error?: undefined on the success member, and
  every "error" in x guard stopped narrowing (TS2339 x5 on the destructure).
  Explicit return types keep the discriminant real; the ApiKeyMetadata alias now
  points at the policy shape (the wider one both sources are assignable to),
  which clears the TS2740 self-mismatch; logger.warn → log.warn (the module
  logger factory has no .warn).

check-api-typecheck.mjs: OK — 283 errors, baseline ratcheted DOWN from 294
(codex-responses-ws 7→4 TS2339, TS2740 1→0; combos/test and keys/[id] 1→0).
typecheck:core clean; 43/43 unit tests in the touched areas green.
2026-09-18 08:13:43 -03:00
Diego Rodrigues de Sa e Souza
b45e0a4b59 fix(i18n): review the 64 non-pt-BR dashboard catalogs for translation quality (#14078)
* fix(i18n): review-locale reviews every leaf of a catalog that did not exist at --since

* fix(i18n): review the 64 non-pt-BR dashboard catalogs for translation quality

Runs scripts/i18n/review-locale.mjs over every locale except pt-BR (done in
#13885): 75,263 corrections applied against the English source, 1,677 of
them reverted because the "correction" replaced a real translation with the
plain English term (the real-translation ratio gate counts those as
untranslated). zh-CN/zh-TW provider term normalised after the run.

review-locale.mjs hardening found by the run: per-batch retries with backoff
(a skipped batch is listed, not fatal), catalog checkpoint every 25 batches,
and setDeep resolving leaf keys that contain a dot.
2026-09-18 07:09:03 -03:00
Fouad Salkini
176d632a2d feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos (#13670)
* feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos

`auto/*` combos currently bypass per-key authorization entirely. They are
virtual — synthesised in the catalog, never stored as combo rows — so
`resolveRequestedComboName()` returns null for them and
`isComboAllowedForKey()` fails open:

    const comboName = await resolveRequestedComboName(modelStr);
    if (!comboName) return { allowed: true, comboName: null };

`validateModelAccess()` then sets `requestedComboName = modelStr` for any
`auto/` id and returns before `isModelAllowedForKey()` runs, so
`allowedModels` and `blockedModels` are skipped for those ids too.

The effect is that `allowedCombos` does not constrain `auto/*`: a key
scoped to a single cheap lane can still send `auto/best-coding` and reach
every model on the gateway. `blockedModels: ["auto/*"]` only unadvertises
the ids — it cannot deny them.

Add an explicit per-key flag instead of tightening the fail-open, which
would silently revoke `auto/*` from every key whose `allowedCombos` lacks
an entry for it. `allow_auto_combos` is NOT NULL DEFAULT 1 and the row
parser treats anything but an explicit falsy value as allowed, so every
existing key keeps working and opting out is deliberate.

When set to false:
  - `validateModelAccess()` rejects `auto/*` for that key;
  - the catalog skips the `auto/*` synthesis loop for it, reusing the
    existing `hideAuto` break so the key is not offered ids it cannot use.

Settable via PATCH /api/keys/[id]. The create path and the dashboard
toggle are deliberately left for a follow-up: the API Manager control
needs UI strings across all message catalogs, which does not belong in
the same change as the policy fix.

* feat(dashboard): add the Auto Combos toggle to API key permissions

Exposes the `allowAutoCombos` flag in the API Manager permissions modal so
the per-key gate can be managed from the dashboard rather than only over
the API.

The control mirrors the prompt-compression toggle: a small dedicated
component, a `role="switch"` button, and labels from the `settings`
message namespace.

Defaults to ON. State reads `apiKey?.allowAutoCombos !== false` — using
`!== false` rather than `=== true` so a key that predates the column, or
one that has never been configured, renders as enabled and matches the
`NOT NULL DEFAULT 1` column.

The field is threaded through all three positional lists (the save
handler signature, the modal prop type and the onSave call) plus the
PATCH payload, so no later argument shifts position.

UI strings are added to en.json and to vi.json. Vietnamese is translated
rather than left as a sync placeholder because
tests/unit/i18n-vi-completeness.test.ts asserts key parity with English
and bans `__MISSING__` markers in that locale. The remaining locales fall
back to English at runtime; `i18n:check-ui-coverage` still passes well
clear of its threshold. They are deliberately not mass-synced here: a
full `i18n:sync-ui` run also replicates ~844 unrelated pre-existing gaps
across all 50 catalogs, which does not belong in this change.

* feat(api): advertise the combo description in /v1/models

A combo's description is stored on its record and returned by
GET /api/combos, but the catalog row never carried it, so no client could
show it.

Claude Code's gateway model discovery reads exactly `id`, `display_name`
and `description` from each entry in the /v1/models `data` array and
renders the description in the /model picker — an entry without one reads
"From gateway" instead. Other OpenAI-compatible clients surface it too.

Emit it only when the combo actually has one, so rows for combos without
a description are byte-identical to before. The value is typeof-narrowed
and trimmed because ComboRecord is Record<string, unknown>, and
`comboMetadata` still spreads last so context and capability metadata
keep precedence.

`display_name` is deliberately not sent: a combo's id is already its
human-chosen name, and the field is only consulted when it differs from
the id.

Ref: https://code.claude.com/docs/en/llm-gateway-protocol.md#model-discovery

* fix(api): list a key's allowed combos in /v1/models

`allowedCombos` gates combos; `modelAccessMode`, `allowedModels` and
`blockedModels` gate provider models. The catalog consulted only the
latter, so a key with `modelAccessMode: "restricted"` and an empty
`allowedModels` received an empty catalog — zero rows — while every combo
in its `allowedCombos` dispatched normally. The catalog contradicted the
key.

Observed on a live gateway: a key with 24 entries in `allowedCombos` and
`restricted` + `allowedModels: []` returned {"object":"list","data":[]},
yet `claude-orchestrate` answered 200 on that same key.

Gate combo rows on `allowedCombos` instead of hiding them. Listing a
combo the key can already dispatch grants no new access, so this is a
consistency fix rather than a relaxation, and it needs no opt-in: the
rule is simply that a key's catalog shows what that key can use.

auto/* rows are exempt. They fail open at dispatch — they resolve to no
stored combo — and their synthesis is already gated by allowAutoCombos,
so gating them here would make the catalog stricter than dispatch.

The decision lives in a new exported helper, isComboNameAllowedForKey(),
which wraps the existing matchesComboAccessRule. An absent list means no
combo restriction, matching validateComboAccess, which skips the check
when allowedCombos is not an array; an empty list allows nothing.

Also advertise `display_name` on combo rows from an operator-set
`displayName` field. Claude Code uses it as the picker entry's name when
it differs from the id, which lets a combo carry a discovery-compatible
id and still read cleanly. It is never derived from the combo name — an
unset field advertises nothing.

* fix(api): accept displayName on the combo schemas

The previous commit advertises `display_name` in /v1/models from a
combo's `displayName`, but neither createComboSchema nor
updateComboSchema declared the field, so Zod stripped it from every
request body and the value could never be set. The endpoint would have
answered 200 and written nothing — the feature was unreachable.

This is the same silent no-op that made `blockedModels` unsettable on
API keys: a field plumbed through the route and the store, missing only
its schema declaration.

Declare it on both schemas and count it in updateComboSchema's "no valid
fields" guard, so a body carrying only `displayName` is a valid update
rather than being rejected as empty. Nullable on update so a label can be
cleared.

* feat(api): add per-key catalogScope to scope what /v1/models advertises

A key had no way to say which kinds of thing its catalog should list. It
always advertised whatever the key's model and combo policies permitted,
mixed together. A client that builds its model picker from /v1/models —
Claude Code's gateway discovery, for one — then sees provider models
alongside the curated combos it was meant to offer.

Add a three-way per-key setting: "all" (default), "combos", "models".

This is a listing preference, not an access control: narrowing it never
changes what the key may dispatch, which the model policy and
allowedCombos continue to decide. That is why it is an explicit setting
rather than implied behaviour — unlike gating combo rows on
allowedCombos, which was a correctness fix and needed no opt-in.

Defaults to "all" everywhere: the column, the parser, the metadata and
the UI state, so every existing key is unchanged. The parser widens to
"all" on an unrecognised value rather than narrowing, so a bad value can
never silently hide rows an operator expects to see.

The dashboard control is a segmented radio group beside the Auto Combos
toggle. UI strings are added to en.json and vi.json; the remaining
locales fall back to English, and vi is translated rather than left as a
sync placeholder because tests/unit/i18n-vi-completeness.test.ts asserts
key parity and bans markers there.

* fix(api): invalidate the model catalog on key visibility changes

updateApiKeyPermissions already advances the unified /v1/models catalog
generation for the fields that change what a key may dispatch, but the two
fields this branch introduces -- allowAutoCombos and catalogScope -- were
missing from that predicate. Both change what the catalog advertises, so a
PATCH toggling either one left the request-shaped catalog cache serving the
previous listing until its TTL expired, and the dashboard's API-key screen
could show a catalog that disagreed with the key it had just written.

Add the two fields to the existing predicate -- no new cache machinery. The
call still runs only after a successful write, so a no-op or failed update
does not invalidate, and unrelated metadata edits (isActive, rate limits)
still leave the catalog cached.

Observed on a live deployment before the fix: PATCH catalogScope="combos"
returned 200 and the column read back "combos", yet GET /v1/models kept
returning the previous mixed rows until a process restart, after which the
same key correctly returned combo-only rows.

* docs(changelog): add fragment for per-key allowAutoCombos and catalogScope

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline the two ceilings this PR's own growth moved

src/app/api/v1/models/catalog.ts 2075 -> 2117 and src/lib/db/apiKeys.ts
1625 -> 1659. Measured on the clean tip first: catalog.ts sits at 2074 (under
its 2075 ceiling) and apiKeys.ts at 1620 (under 1625), so none of this is
inherited — it is the feature itself. Gating the built-in auto/* combos per key
means the permission field has to be read, validated and carried all the way to
the catalog filter, and each of those is an explicit call site rather than
something extractable without hiding the gate.

Covered by the PR's 25 tests. The other violations in this tree (chatHelpers.ts,
chatCore.ts, chatcore-translation-paths.test.ts) are inherited base-reds and were
left untouched.

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:53:16 -03:00
Bob.Hou
b9dd80c8c2 fix(chat): preserve suffix reasoning intent across model attempts (#13720)
* chat/suffix-effort: keep reasoning intent tied to each model attempt

Carry resolved suffix effort through dispatch without treating a derived
value as explicit client input. Prepare reasoning defaults and dependent
parameter constraints for each handler attempt so a replacement model
does not inherit the original model's suffix.

Keep explicit reasoning choices in context-aware request hashes to avoid
sharing concurrent responses across different effort settings. Preserve
the legacy hash interface and tenant namespace.

Exercise retries, credential refresh, tool follow-ups, replacement models
and overlapping requests with local HTTP and targeted regression tests.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* chat/upstream-body: separate normalization from async payload preparation

Keep synchronous per-attempt normalization together so payload preparation
stays within the function size and complexity limits without changing its
ordering or explicit reasoning semantics. Condense redundant provider
selection comments to retain the formatted file within its size ceiling.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* changelog: record the suffix-effort propagation fix

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(quality): rebaseline file-size cap for chatHelpers.ts growth

The release tip independently grew src/sse/handlers/chatHelpers.ts from
1164 to 1213 lines while the frozen cap sat at 1214; this PR's own +3
lines (threading resolvedThinkingEffort through resolveModelOrError and
executeChatWithBreaker) push the merged result to 1217, past the cap.
Owner-approved exception for this file only, with the measured growth
breakdown recorded in the baseline entry.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:52:59 -03:00
Aaron Scherer
7d69b02a29 fix(db): skip integrity scans during health polling (#13149)
* fix(db): skip integrity scans during health polling

* test(db): update health error fixture for scan-free polling

* docs(changelog): add fragment for health poll integrity skip

* fix(db): keep the #13149 dashboard skip inside the #13717 managed health check

Merge fallout only: runManagedDbHealthCheck moved behind the health
coordinator on the release tip, so the per-call skipIntegrityCheck now
travels through it. A waived integrity scan is part of the job identity,
so it is never replayed from the 60s diagnosis cache to a caller that
asked for the full scan.

Co-authored-by: cryptiklemur <cryptiklemur@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: cryptiklemur <cryptiklemur@users.noreply.github.com>
2026-09-17 18:52:43 -03:00
Bob.Hou
5f9e153971 fix(mcp): fall back when better-sqlite3 export is not callable (#13903)
* mcp/audit: fall back when better-sqlite3 export is not callable

Dashboard MCP status polls reopen a failed native sqlite load every 30s
because a minified TypeError ("a is not a function") was not treated as
a native load failure and a failed open was not cached. Classify that
shape, fall back to node:sqlite, cache the miss, and refuse to ship a
Docker image without better_sqlite3.node.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* mcp/audit: force native better-sqlite3 compile in Docker

better-sqlite3 13 ships a linux prebuild. Bare `node-gyp rebuild`
then only TOUCHes stamp files and never writes
build/Release/better_sqlite3.node, so the new test -f gate fails the
image build. Pass --force_build=1, matching the package's own
build-release script.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* db/core: keep native-load classification under the file-size cap

The audit fallback added two TypeError fingerprints in core.ts and
crossed the frozen 1788-line cap. Move the classifier into
sqliteLoadError.ts and re-export it so existing importers stay stable.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* build/bootstrap: keep the encrypted-credentials probe narrow

The native-load classifier was copied into scripts/build/bootstrap-env.mjs
alongside the runtime one, but the two files consume its verdict in opposite
directions. In src/lib/db/sqliteLoadError.ts a true verdict means "the driver
is unusable, cascade to node:sqlite", so treating a non-callable export as a
load failure is what we want. In the bootstrap the verdict feeds
hasEncryptedCredentials, where true means "no encrypted credentials found" and
clears the way to generate a fresh STORAGE_ENCRYPTION_KEY.

With the TypeError patterns in the bootstrap copy, a binding that loads but
exports something non-callable over a database full of enc:v1: rows reads as an
empty database, and the operator silently loses access to every stored
credential. Drop those two patterns from the bootstrap copy only, and note in
both files why the pair is deliberately not identical.

A corrupt binding still fails loudly there, now with the database path, the
underlying message, and a rebuild hint, so the narrower classifier does not
cost any diagnosability.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(mcp): keep audit logging recoverable when the database is created later

getDb() cached a null for the "storage.sqlite does not exist yet" branch, and
closeAuditDb() returns before clearing a falsy cache — so an MCP server started
before the app created the database stayed without audit logging for the whole
process lifetime. Only a genuine driver-load failure is cached now; the
not-found branch retries, which is how it recovers when the file appears.

Covered by a new test that fails without the change.

Also replace the fabricated minified TypeError text ("a is not a function")
thrown by the loader with "better-sqlite3 export is not a function": the
operator sees a diagnosable message and isNativeSqliteLoadError() still
classifies it (it matches on "is not a function").

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:47:25 -03:00
Bob.Hou
4be37d149b feat(dashboard): expose comboTimeoutMs next to Target timeout (#13857)
* feat(dashboard): expose comboTimeoutMs next to Target timeout

The runtime already applied config.comboTimeoutMs as the whole-combo
wall-clock budget (0 = 10-minute hang-stop). Schema treated it as an
unknown passthrough key and the dashboard only painted Target timeout,
so operators could not raise the 15-step failover ceiling from the UI.

Declare comboTimeoutMs on comboRuntimeConfigSchema, mount both knobs in
the combo editor Advanced panel and Combo defaults, and keep
comboTimeoutMs longer than targetTimeoutMs so failover still has time.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* docs(changelog): attach #13857 to comboTimeoutMs fragment

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* test(dashboard): name comboTimeoutMs store as milliseconds

The input is seconds; the stored config field is milliseconds. The old
title said "in seconds" while asserting 1_200_000.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* chore(i18n,changelog): translate #13857 keys into all locales and drop the CHANGELOG hunk

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:47:07 -03:00
Bob.Hou
ec780ef2dc feat(compression): make Lite tool-result truncation length configurable (#13915)
* feat(compression): make Lite tool-result truncation length configurable

Lite truncated tool results at a hardcoded 2000 characters. Coding-agent
payloads (file reads, crash dumps) lost the middle of the content with no
supported way to raise the cap.

Honor lite.maxToolLength from settings, then OMNIROUTE_LITE_MAX_TOOL_LENGTH,
then 2000. Existing installs keep the old length.

Related to #13178.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
#13178 stays open.

* compression/lite: keep a stored cap when a step or toggle write is incomplete

An out-of-range step maxToolLength was still a number, so it hid a valid
global cap and fell through to env. A toggle-only settings PUT replaced
the whole lite row and dropped the stored cap. Save treated an out-of-range
number like a cleared field. Reject the bad Save, merge omitted caps, and
use null to clear.

Related to #13178.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* compression/lite: stop dashboard copy from hard-coding a 2000-char cap

The page overlays schema descriptions from i18n. Updating only
LITE_SCHEMA left operators seeing "over 2,000 characters" after the
cap became configurable. Also assert the Save error string, not the
Save button.

Related to #13178.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* chore(changelog): move #13915 entry to a changelog.d fragment

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:46:45 -03:00
Innokentiy Solntsev
e7872e57c3 fix(db): reclaim freed pages incrementally instead of a blocking VACUUM in the cleanup scheduler (#12821) (#12830)
* fix(db): reclaim freed pages incrementally instead of a blocking VACUUM in the cleanup scheduler (#12821)

startCleanupScheduler() ran a synchronous whole-database VACUUM on the event
loop whenever a cleanup pass deleted at least one row - 30 s after every
start and every 6 h. With node:sqlite that blocks every route (/healthz
included) for the duration: 7 min 55 s on a 540 MB storage.sqlite to reclaim
six rows. It also bypassed vacuumScheduler, the app-level owner of full
VACUUMs and the operator's scheduledVacuum / vacuumHour settings.

cleanup.ts no longer issues a full VACUUM. After each pass reclaimFreedPages()
branches on PRAGMA auto_vacuum:

- INCREMENTAL: drain the freelist with PRAGMA incremental_vacuum(N) in ~1 MiB
  batches (N from page_size), pausing between batches for as long as the last
  one took (<=250 ms), PASSIVE checkpoint every 64 batches and a TRUNCATE
  checkpoint at the end so the main file shrinks in WAL mode; hard caps of
  2048 batches / 30 s per pass, the remainder waits for the next pass.
- FULL: nothing to do, SQLite reclaims on commit.
- NONE: incremental_vacuum is a no-op, so record a request via the new
  vacuumScheduler.requestFullVacuum(); the rebuild runs in the configured
  window (or via the Storage page button). scheduledVacuum=never is honored.

vacuumScheduler persists fullVacuumRequestedAt / fullVacuumRequestReason,
clears them on the next successful runNow(), and hydrates from key_value
before an early request so it cannot clobber a persisted lastRunAt.

Loop robustness: db.exec() rather than pragma() (bun:sqlite's all() steps a
zero-column pragma once), SQLITE_BUSY/LOCKED and a handle closed under the
pass stop it quietly, other errors stop it with partial progress logged.
Also drops the duplicate cleanupProxyLogs() call in the scheduled pass -
runAutoCleanup() already covers proxy_logs.

Tests: new tests/unit/db/cleanup-reclaim-freed-pages.test.ts (INCREMENTAL
drain/pause/checkpoint, page_size-derived batch, caps, FULL no-op, NONE
defers and leaves page_count untouched, runScheduledCleanupPass() path);
vacuum-scheduler.test.ts covers requestFullVacuum persistence, restart
survival and clearing; cleanup-column-fix.test.mjs now asserts
incremental_vacuum and the absence of a full VACUUM statement.

* chore(changelog): name the #12821 fragment after its PR (#12830)

* fix(db): extract reclaimFreedPages into its own module and fix full-suite regressions

Split the #12821 incremental-vacuum reclamation logic out of cleanup.ts
into src/lib/db/reclaimFreedPages.ts (re-exported for callers/tests) so
cleanup.ts stays under the file-size cap after the #13011 reconciliation
merge grew it past the 1200-line threshold.

Also fixes two full-suite failures surfaced by running the
cleanup/vacuumScheduler/db-health suite post-merge (not just this PR's
own 3 test files, per the plan-file's mandatory item):

- tests/unit/cleanup-column-fix.test.mjs scanned cleanup.ts's raw source
  for the PRAGMA incremental_vacuum invariant, which now lives in the
  extracted module — updated to scan both files.
- tests/unit/db/cleanup-reclaim-freed-pages.test.ts asserted the freelist
  count is byte-for-byte unchanged when auto_vacuum=NONE. The tip's
  runAutoCleanup() now also runs cleanupCompressionRunTelemetry(), which
  lazily creates its table on first use (ensureCompressionRunTelemetryTable)
  — a legitimate one-time page cost from a freshly migrated DB, unrelated
  to reclaimFreedPages()'s own behavior. Loosened the assertion to a small
  tolerance while keeping the page_count assertion that actually guards
  against a full rebuild.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(db): drop the reclaimable-bytes VACUUM gate test superseded by incremental reclaim

tests/unit/vacuum-reclaimable-threshold.test.ts pinned cleanup.ts's
vacuumAfterCleanup()/getReclaimableBytes()/getVacuumMinReclaimableBytes()
(#13079). This branch removes the inline post-cleanup full VACUUM entirely in
favour of reclaimFreedPages() (#12821), which reads the same freelist_count /
page_size signal and defers a full VACUUM to the vacuum scheduler when
auto_vacuum=NONE. With those three exports gone the file cannot compile, and
the behaviour it guarded no longer exists.

---------

Co-authored-by: insoln <is@careerum.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:38:26 -03:00
Innokentiy Solntsev
241e63bfea feat(usage): redeem GLM Coding Plan Reset Cards from Provider Limits (#12754)
* feat(usage): redeem GLM Coding Plan Reset Cards from Provider Limits

z.ai sells Reset Cards that clear an exhausted GLM coding-plan window (5-hour or
weekly) ahead of its natural rollover, but OmniRoute only ever read the passive
nextResetTime, so redeeming one meant leaving the dashboard.

Add the wire layer for z.ai's two reset endpoints
(/api/biz/customer-package-reset/list and /use), which authenticate with the same
Bearer API key as /api/monitor/usage/quota/limit and report failures inside an
HTTP-200 envelope, so callers must inspect success/code rather than the status line.

The banked count rides along with the quota poll - only for keys that actually
report a resettable window, and strictly best-effort so a card-less account or a
transient failure still renders its quotas. The existing reset-credit card, picker
and confirmation flow, until now gated to Codex, now also drive glm/glm-cn/glmt/zai
through the new /api/usage/glm-reset-card route, reusing z.ai's requestId as the
idempotency key so a retry cannot burn two cards.

* test(usage): cover GLM reset-card edge cases

* test(dashboard): require GLM reset-card copy

* fix(usage): harden GLM reset-card redemption

* fix(usage): treat missing GLM key as empty

* fix(usage): fence GLM reset-card operations and coalesce lease-window duplicates

- Acquire a synthetic 60s exclusive-connection lease around each list/use
  wire operation; release in finally so a competing lease can acquire
  immediately after success or failure.
- Coalesce same-key duplicates that arrive after lease acquisition by
  checking the in-flight attempt before loading the connection.
- Run the post-commit quota refresh outside the lease (redemption is
  already committed; the refresh is auxiliary and failure-tolerant).
- Do not discard a retained ambiguous attempt on a lease-conflict 409.
- Harden transport error mapping: static messages for proxy transport
  failures, keep explicit direct routing for unproxied connections
  through list, use, and refresh.

* fix(i18n): sync GLM reset-card keys to pt-BR and vi locales

---------

Co-authored-by: insoln <is@careerum.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:38:09 -03:00
Nguyen Thanh Dat
ac0a63117e fix(providers): read the vLLM context window from max_model_len (#12897)
* fix(providers): read the vLLM context window from max_model_len

normalizeDiscoveredModels resolved the window from inputTokenLimit,
context_length, contextLength and top_provider.context_length. vLLM
reports it as max_model_len and nothing else, so a synced vLLM model
carried no inputTokenLimit and the resolver fell back to the 128K
default - half the window on a 250K deployment.

The native vllm provider and the OpenAI/Anthropic-compatible custom
providers all pass raw records through this function, so one chain entry
covers the three connection shapes.

Closes #12858

* docs(changelog): fragment for #12897

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:37:34 -03:00
Paco Cartones
1deb77a00d test(dashboard): reactivate discovery page coverage (#13842)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-17 17:16:47 -03:00
Paco Cartones
6f2eafd138 test(dashboard): reactivate API endpoints coverage (#13841)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-17 17:16:22 -03:00
Paco Cartones
4be690736c test(dashboard): reactivate webhook wizard coverage (#13844)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-17 17:15:58 -03:00
Fouad Salkini
ec60915d91 fix(api): accept blockedModels in the key permissions schema (#13666)
* fix(api): accept blockedModels in the key permissions schema

`PATCH /api/keys/[id]` already destructures `blockedModels`, forwards it
into the update payload, and `updateApiKeyPermissions()` writes it to the
`blocked_models` column. Only the first link was missing:
`updateKeyPermissionsSchema` never declared the field, so Zod stripped it
from the parsed body and the destructured value was always `undefined`.
The request answered 200 and wrote nothing.

The API Manager permissions modal sends `blockedModels` on every save
(ApiManagerPageClient.tsx), so the Claude-Code family-blocking control
silently did nothing and an existing deny-list could not be cleared.
`blockedModels` is the deny-list half of the model policy — read by
`isModelAllowedForKey()` before the allow-list and winning over it — so
that half was only reachable by editing the database by hand.

Declare the field mirroring `allowedModels` (trimmed, non-empty, max
1000) and count it in the "No valid fields to update" guard so a body
carrying only `blockedModels` is a valid update.

Left out of `createKeySchema` deliberately: the create route does not
read `blockedModels`, so declaring it there would be dead weight.

* docs(changelog): add fragment for blockedModels key schema fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 16:26:42 -03:00
Innokentiy Solntsev
c97f61b2ac fix(sse): prevent Anthropic 400s for Claude-native handoffs (#12668)
* fix(sse): prevent Anthropic 400s for Claude-native handoffs

* docs(changelog): add fragment for Claude-native handoff 400 fix

* refactor(sse): satisfy file-size and complexity ratchets

Keep the Claude wire-body guard while staying under the frozen per-file line baselines and the complexity ratchets measured against release/v3.8.51.

Extract the final constraint coordinator, split system-message normalization into focused helpers, and isolate handoff response parsing. Reflow the universal-handoff explanation to absorb the added source-format argument without growing the frozen file.
2026-09-17 16:26:25 -03:00
Innokentiy Solntsev
dd70dbdaa0 fix(sse): Anthropic OAuth 403 "Request not allowed" is a per-request refusal — cooldown with backoff instead of an instant ban (#12859) (#12864)
* fix(sse): Anthropic OAuth 403 "Request not allowed" is a per-request refusal, not a ban

A single upstream 403 on the `claude` OAuth connection was classified
FORBIDDEN and written as the terminal `banned` connection state
(chatCore -> writeTerminalStatus). From then on every request to that
provider was short-circuited with "All 1 connection(s) banned by
upstream - please reconnect in the dashboard" without touching Anthropic,
until an operator reconnected.

Anthropic's OAuth surface answers a small fraction of otherwise-valid
requests with 403 {"type":"permission_error","message":"Request not
allowed"}. On the reporting install the same token returned 200 forty
seconds before the 403 and again right after the connection was
re-enabled; a revoked or expired token is a 401 authentication_error, not
this. It is a refusal of one request, not of the credential.

Classify it as the new non-terminal PROVIDER_ERROR_TYPES.REQUEST_REJECTED
(scoped to provider `claude` and the "Request not allowed" body) and list
that type in authTerminalStatus.isNonTerminalProviderError, mirroring the
Cloudflare FINGERPRINT_REJECTION precedent. The combo layer still falls
through to the next target for the failing request; the connection stays
active for the next one. Any other claude 403 keeps its previous
classification.

Tests: error-classifier.test.ts covers the Anthropic body, the
gateway-flattened "[403]: Request not allowed" message, the same body from
a non-Anthropic provider (still FORBIDDEN), other claude 403s (unchanged),
and the helpers; anthropic-request-not-allowed-not-a-ban.test.ts pins
resolveTerminalConnectionStatus() -> null for the new type even with a
`permanent` fallback verdict, and `banned` for a generic claude 403.

* fix(sse): cooldown with backoff and streak escalation for REQUEST_REJECTED (#12859)

Not "ignore the 403" either: if Anthropic ever made "Request not allowed"
systematic, re-sending every request into it would be the wrong thing to
do to an OAuth account. chatCore now handles REQUEST_REJECTED explicitly:

- exclude the connection via setConnectionRateLimitUntil for a growing
  cooldown (5 -> 15 -> 45 min) so a sporadic refusal costs minutes, not a
  reconnect, and a systematic one cannot become a stream of 403s;
- escalate to the terminal `banned` state only for 3 refusals within a
  60-minute window (services/requestRejectedStreak.ts, in-memory per
  connection; a restart forgets the streak, erring towards more cooldowns
  rather than an operator-undone ban), with a last_error that says so;
- probe-origin failures record but never cool down or ban (#9817).

The existing "request not allowed" text rule (5 s) is unaffected:
markAccountUnavailable skips a connection that already has a future
rateLimitedUntil, so the minute-scale cooldown written here wins.

Tests: request-rejected-streak.test.ts pins the window/threshold/backoff
arithmetic; anthropic-request-not-allowed-cooldown-escalation.test.ts drives
the real chat route against a mocked 403 upstream on a `claude` OAuth
connection: 300 s cooldown, then 900 s, then banned on the third refusal;
a different claude 403 body still bans on the first response.

* chore(changelog): name the #12859 fragment after its PR (#12864)

* refactor(sse): move the REQUEST_REJECTED branch into a chatCore leaf; register its tests for mutation coverage

chatCore.ts is frozen at 5984 lines by the file-size ratchet; the branch
body now lives in open-sse/handlers/chatCore/requestRejectedFailure.ts
(chatCore: 5974 -> 5983). stryker.conf.json tap.testFiles gains the two new
DB-backed tests so their mutant kills count (check:mutation-test-coverage).

* fix(sse): count refusal episodes, reset on success, keep the dashboard honest (#12859 review)

Review findings on the first cut of the REQUEST_REJECTED handling:

- A burst of in-flight requests that all got the 403 within seconds
  produced streak 1, 2, 3 and a ban from one upstream event. The streak
  now counts cooldown *episodes*: a refusal that lands while the
  connection is already excluded is the same event and is not counted.
- Nothing reset the streak on a healthy response, so sporadic refusals
  on a busy install could still accumulate to a ban. chatHelpers'
  onRequestSuccess now clears it (only a real success does - the recovery
  tick's clearAccountError is an elapsed cooldown, not a success).
  Clearing the cooldown by hand in the dashboard clears it too.
- The third rung of the ladder was unreachable (the third refusal
  escalates): the ladder is now 5 -> 15 min, sourced from COOLDOWN_MS next
  to the existing 5 s "request not allowed" rule, with a note on why that
  rule is superseded for claude. The 60-min window becomes a 24 h
  staleness bound - "consecutive" is defined by successes, not by time.
- Probe-origin refusals no longer touch the streak (#9817).
- The cooldown is written like every other connection-level cooldown:
  ISO rateLimitedUntil + testStatus "unavailable" (+ lastErrorAt), so the
  dashboard shows the countdown and the recovery tick restores "active".
- One refusal is re-seeded from the persisted row after a restart so a
  crash loop cannot reset the count on every boot.

Docs: RESILIENCE_GUIDE terminal states + CODEBASE_DOCUMENTATION resilience
row mention the streak module. Tests cover the burst, the success reset,
the seed, and the ISO/unavailable shape end-to-end through the chat route.

* chore(sse): drop unrelated Prettier churn in auth.ts / providers route

* style(api): keep providers route Prettier-clean

* refactor(sse): share the "exclude connection for a cooldown" leaf between GEO_BLOCKED, GCP_PROJECT_REQUIRED and the new branch

The release tip moved chatCore.ts to its frozen 5984 lines, so the
REQUEST_REJECTED branch cannot add a single net line. The GEO_BLOCKED and
GCP_PROJECT_REQUIRED branches were the same eight statements with different
constants and log wording; both now call
open-sse/handlers/chatCore/connectionCooldown.ts::excludeConnectionForCooldown
(behaviour, probe guard and log lines preserved verbatim). chatCore.ts ends
9 lines below the base it branched from.

* chore(chatCore): tighten the cooldown comments to keep the file under its size ceiling

After merging release/v3.8.51, chatCore.ts sat at 6150 lines against a
frozen ceiling of 6146. Condense the explanatory comments this PR added
to the GEO_BLOCKED and GCP_PROJECT_REQUIRED branches; no code change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: insoln <is@careerum.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 16:26:08 -03:00
Nguyen Thanh Dat
21d756d7f0 fix(combos): accept isHidden in updateComboSchema (#12898)
* fix(combos): accept isHidden in updateComboSchema

A combo's visibility is stored on the record and honoured by the builder
option list and the dashboard grid, but updateComboSchema never listed
isHidden. The PUT handler spreads the validated body, so zod stripped the
field: a visibility-only update was rejected as "No valid fields to
update", and a mixed update succeeded while dropping the visibility
change.

Closes #12836

* docs(changelog): fragment for #12898
2026-09-17 16:25:16 -03:00
Lance Woodson
8074e3d596 fix(resilience): honor declared effort vocabulary in reasoning rule gate (#12686)
* fix(resilience): honor declared effort vocabulary in reasoning rule gate

The reasoning-routing rule capabilityFor() hardcoded a gpt-5.6-(sol|terra|luna)
whitelist for forced max/ultra, rejecting every other thinking-capable model
even when the model's resolved capabilities declare the requested tier (synced
supportedThinkingEfforts or an operator Model Overrides reasoning_efforts
override). This 400'd direct calls with "Reasoning effort 'max' is not
supported by the configured target" for models like Merge Gateway
zai/glm-5.3-flash, which natively accepts low|high|max.

The gate now treats a declared vocabulary containing the requested tier as
authoritative, mirroring the dispatch-time sanitizer
(open-sse/executors/base/reasoningEffort.ts) which already forwards declared
tiers verbatim. Undeclared models keep the legacy gpt-5.6 regex verdicts and
the unknown passthrough.

* fix(resilience): gate forced max against the static registry the sanitizer clamps with

Adversarial review finding: the gate read supportedThinkingEfforts from
getResolvedModelCapabilities, which prefers the DB override over the registry.
For a registered model with a narrow registry vocabulary and a widening
operator override, the gate passed forced max but the dispatch-time sanitizer
(executors/base/reasoningEffort.ts) clamps against the STATIC registry and
would silently downgrade max to the registry ceiling — converting a loud 400
into a silent wrong-effort request.

Order of precedence in the gate now:
1. static registry vocabulary (authoritative — matches sanitizer clamping)
2. declared/overridden vocabulary for unregistered providers (#8057 path)
3. legacy gpt-5.6 regex, then unknown/unsupported verdicts

Also pins the test fixture to a synthetic model id so a future models.dev
sync row cannot flip the unknown-precondition assertion.

* fix(resilience): gate registry lookup mirrors the dispatch sanitizer exactly

Review findings on the forced max/ultra gate:
- resolve the registry through getProviderModels (id->alias namespace) and
  match entry aliases, mirroring reasoningEffort.ts — a raw provider id or
  alias-spelled model no longer skips the registry branch and diverges from
  dispatch clamping
- treat an empty declared vocabulary as no declaration (falls through),
  matching the sanitizer's declaredRanked.length>0 guard — before, a model
  declaring [] was gated to unsupported while dispatch forwarded verbatim
- an operator-declared vocabulary that excludes the forced tier is terminal;
  the legacy gpt-5.6 regex can no longer resurrect a tier the override
  narrowed away
- rewrite the registry-outranks-override test: create the matching rule so
  the decision is non-null, assert unconditionally, pin gpt-5.6 narrowing,
  alias namespace parity, and use the deterministic xai/grok-4.6 fixture

* docs(changelog): clarify override scope for registry-declared models

* test: drop placeholder issue reference from test names

* chore(changelog): name fragment after PR #12686
2026-09-17 16:24:58 -03:00
Lance Woodson
821d02ba13 fix(providers): parse per-vendor-route reasoning.effort_values in discovery (#12730)
OpenAI-compatible model discovery does not recognize per-vendor-route
reasoning vocabularies declared under vendors.<vendor>.capabilities.reasoning
in GET /v1/models (Merge Gateway's documented catalog shape), so synced
models carry no supportedThinkingEfforts/defaultThinkingEffort and operator
effort data resets on every model sync; models whose upstream accepts a
native max tier cannot be used with forced-max reasoning rules.

Parse the shape into the existing supportedThinkingEfforts pipeline,
intersected across vendor routes: the same canonical model declares
different vocabularies per route and unpinned requests self-narrow to a
route honoring the requested level, so a synced tier must be honored on
every route the model can land on. Routes without effort_values declare
no effort control and are excluded; disjoint vocabularies produce an
authoritative empty list (no fall-through to generic tier shapes).

detectDefaultThinkingEffort falls back to the intersection's highest tier
ranked by the canonical effort order — only when the vendors shape is the
record's winning vocabulary source, never escaping a flat or nested
declared list.

Detection is shape-gated, not provider-gated; Zod-validated (Hard Rule #7)
with malformed vendor and tier entries dropped individually (discarding a
whole route would widen the intersection, fail-open). Precedence: flat
field > reasoning.supported_efforts / metadata (#7694) > vendor-route
intersection > capabilities.effort_tiers (#9160) / supported_reasoning_levels
/ thinking.levels (#8347).
2026-09-17 16:24:39 -03:00
Aaron Scherer
46730700f1 feat(usage): show separate Fable weekly limits (#13266)
* feat(usage): show separate Fable weekly limits

* docs(changelog): add fragment for fable weekly usage
2026-09-17 16:23:46 -03:00
Diego Rodrigues de Sa e Souza
83fa4328f3 feat(providers): add xKiro (#12648)
* test(catalog): pin the 2026-09-02 free-tier re-audit facts for gemini, ollama-cloud, groq, nara and mistral

* feat(providers): add xKiro (5M tokens/day free plan, 39 pinned free models)

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

* docs(providers): xKiro in the provider reference, counts and free-tier headline (~1.66B)

* 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(providers): align the remaining visible provider/executor counts with the catalog

* docs(providers): align remaining free-tier count chips and metadata

* 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(providers): re-sync provider and free-tier counts after merging release/v3.8.51

* docs(providers): re-sync residual counts after the base merge

* 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)

* chore(quality): rebaseline gateways.ts file-size cap for the xKiro entry (+20)

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-09-17 13:34:45 -03:00
Diego Rodrigues de Sa e Souza
b6975537c1 fix(providers): remove the chipotle/pepper provider (#13131) (#13913)
* fix(providers): remove the chipotle/pepper provider (#13131)

amelia.chipotle.com (the reverse-engineered Amelia chat-widget backend
chipotle/pepper-1 talked to) now returns 404 on every route, including
root, from its Azure Application Gateway — confirmed live 2026-09-15.
This regressed from a WS handshake timeout (#4037, June 2026) to a
fully decommissioned host, so the upstream protocol cannot be fixed.
Owner decided to retire the provider entirely (Option B), following
the phind/kluster quiet-removal precedent: no REMOVED_PROVIDERS.md
entry (reserved for operator takedowns), just a one-line note under
FREE_TIERS.md "Removed / no free tier".

Removed every surface: executor, registry entry, executors/index.ts
and providers/index.ts wiring, noauth provider catalog entry,
ProviderIcon generic-fallback set, the autoCombo exclusion-list
comment, the chipotle_error code from the sanitizer allowlist,
PROVIDER_REFERENCE.md (regenerated), and every doc/test reference.

Regression test: tests/unit/issue-13131-chipotle-provider-removed.test.ts
asserts the provider is fully gone from the executor registry, the
provider REGISTRY and the noauth catalog, and that the executor module
no longer resolves — not a live-network repro (flaky/third-party).

Several existing tests used "chipotle" only as a generic noAuth-provider
example (proxy scoping, error classification, onboarding, fallback
text) with no chipotle-specific behavior under test; those were
re-pointed at another still-existing noAuth provider
(cloudflare-playground / duckduckgo-web) rather than weakened.

* test(providers): document the agnes-cn/chipotle count coincidence (#13131)

provider-node-reserved-prefix.test.ts's REGISTRY id+alias walk was
already red on the base tip (414 vs. expected 412) from agnes-cn
(#13399, +id/+alias). Removing chipotle's REGISTRY id/alias in this
PR nets it back to 412, making the test pass again without a numeric
edit — record why in a comment so it doesn't read as an untracked
coincidence later.
2026-09-17 13:22:09 -03:00
Diego Rodrigues de Sa e Souza
872376bdc1 fix(chat): reject null/non-object entries in messages[] (#12643) (#13755)
* fix(chat): reject null/non-object entries in messages[] (#12643)

A messages array containing null (or any non-object entry, e.g. [null] or
[42]) passed every existing entry guard in chat.ts (#5110/#6402/#6407/#6412)
and reached downstream translators/session helpers that read `.role` /
`.content` directly off each entry (openai-to-claude.ts, sessionManager.ts,
contextManager.ts's fixToolPairs), crashing with a raw TypeError and
surfacing as an HTTP 500 instead of a clean 400. The route's Zod schema is
intentionally wide (z.array(z.unknown())), so this shape check belongs in
the handler's guard chain. Adds one more entry-shape guard clause to the
same chokepoint, rejecting the request with a clear 400 before any routing
or upstream call.

Regression test: tests/unit/chat-messages-entry-objects-12643.test.ts

PR #12644 (@soroush5) proposed this exact fix but was closed without
merging on 2026-09-12; this re-implements it fresh against the current tip
using the same guard shape and error message.

Originally-proposed-by: @soroush5 in #12644
Co-authored-by: soroush5 <mrsoroushahmadi@gmail.com>

* chore(quality): refix the chat.ts ceiling for the merged tree

This branch rebaselined src/sse/handlers/chat.ts against an older tip. After
merging the current release tip the combined file is 2520 lines, so the 2500
ceiling no longer covers it.

The tip alone is already at 2509 — above the 2500 this PR had frozen — so most
of the gap is inherited, not introduced here. This PR's own contribution is the
+10 of the messages-entry guard itself. Ceiling refixed at the value the gate
reports for the merged tree.

---------

Co-authored-by: soroush5 <mrsoroushahmadi@gmail.com>
2026-09-17 13:16:02 -03:00
initguru
b7192b72e2 fix(thinking): parse/scrub DSML tool-call markers and recognize adaptive thinking (#12905)
* fix(thinking): recognize adaptive thinking + parse/scrub DSML tool-call markers

Two defects combined to break DeepSeek-V4-Flash turns and raise 502
empty_response on Claude Code autocompact.

Defect 1 — DSML tool-call markers leaked as visible content:
DeepSeek-V4-Flash occasionally emits tool calls in a non-standard DSML
text format using full-width pipes instead of the OpenAI tool_calls JSON.
Two shapes appear in production call logs:
  - complete block: <|DSML|:Read><path>...</path></|DSML|:Read>
  - stray closers (truncated call): </|DSML|parameter></|DSML|invoke>
    </|DSML|tool_calls>, sometimes trailing a system-prompt echo
The openai-compatible path never parsed these, so the markers leaked to
the client as visible content and the turn ended incomplete.

Fix: add open-sse/utils/dsmlToolCalls.ts — parseDsmlToolCalls() converts
complete DSML blocks into OpenAI tool_calls and strips stray closing
markers from content (streaming-safe via a holdback for partial openers).
Wire it into the response translator before extractXmlInvokeBlocks so
DSML and XML invoke tool calls share the same pending queue.

Defect 2 — adaptive thinking silently suppressed:
A prior inline === 'enabled' check on body.thinking.type silently
suppressed adaptive (the intent Claude Code actually sends), so
reasoning was dropped. The model then emitted DSML tool-call markers
as plain text, producing an incomplete stop finish. Fix: use
hasActiveClaudeThinking() (which recognizes enabled AND adaptive) to
set requestedThinking, thread it through stream.ts and translator
state, and gate thinking block emission on state.requestedThinking
so upstream reasoning_content only relays when the client opted in.

Tests: 29/29 (6 dsml-tool-calls, 5 thinking-active-claude-adapter,
3 translator-resp-dsml-integration, 15 translator-resp-openai-to-claude
incl. requestedThinking suppression regression). typecheck:core clean.

* fix(sse): strip echoed system-prompt preamble + preserve large analysis/summary blocks

DeepSeek-V4 and similar models echo the OMNIROUTE_SYSTEM_INSTRUCTION_APPEND
directive (appended to the system tail by claude-to-openai.ts) and whole chunks
of the system prompt (<analysis>/<system-reminder>/<summary> blocks, prose
reproductions of the superpowers skill section) verbatim at the START of their
reply — the 'system message leak' persisting after the request-side fix.

Add two streaming-safe preamble strippers in directivePreambleStripper.ts:
- createDirectivePreambleStripper(directive): drops a leading reproduction of
  the exact configured directive across arbitrary SSE chunk boundaries.
- createSystemPreambleStripper(): removes <analysis>/<system-reminder>/
  <summary> echo blocks and known prose heads (Phase B) from the very start
  of a stream, only while the stream is still a preamble.

Wire both into openai-to-claude.ts content-delta path: chain the exact-directive
stripper then the system-echo stripper before DSML/XML-invoke parsing, so a
leading system echo is dropped before it reaches the client.

Preserve large blocks (>= SYSTEM_ECHO_THRESHOLD=1000 chars) and blocks with no
trailing content — these are the model's real response (e.g. a Claude Code
autocompact summary), not a short system-echo. Stops the autocompact
empty-response regression where a whole-summary <analysis> block was stripped
to empty (3a8515).

Regression: origin's markdown-boundary feature (bufferedPrefix /
splitMarkdownBoundary, commit 1b39873ea) is preserved — preamble strip runs
before the markdown buffer rehydration, and the scrubbed content flows into
the existing DSML/XML-invoke/markdown pipeline unchanged.

TDD: tests/unit/directive-preamble-strip.test.ts (7 cases),
system-preamble-strip.test.ts (12 cases incl. 3a8515 regression),
system-preamble-wiring.test.ts (3 integration cases); group F regression
24/24 green; typecheck:core 0 errors.

* fix(sse): gate thinking block on requestedThinking + synthesize text block for reasoning-only responses

Reasoning-content (thinking) blocks were emitted unconditionally to
Claude-format clients, leaking reasoning to thinking-opt-out clients
(Claude Code sends thinking:{type:"disabled"}) — the operator reported
'reasoning is exposed'. On reasoning-only upstream responses (GLM-5.2
autocompact pattern), the unconditional thinking block also caused either
a 502 'no content block' at flush, or — after a text-block fallback — an
autocompact 'empty response' rejection that looped the session forever.

Streaming translator (openai-to-claude.ts):
- Compute hasReasoning outside the emission gate; accumulate into
  state._reasoningAccum always (so fix B can fire).
- Gate only the thinking-block EMISSION on requestedThinking === true.
- FIX B at finish: when no text block was started and requestedThinking
  !== true, synthesize a text content block from _reasoningAccum so
  autocompact can extract the summary (no 502, compact applies).
- Skip fix B when requestedThinking === true to avoid double-exposure
  (thinking block + text block both carrying reasoning).

Non-streaming translator (responseTranslator.ts):
- Thread requestedThinking through translateNonStreamingResponse into
  convertOpenAINonStreamingToClaude.
- suppressThinking = requestedThinking === false: drop the thinking block
  when content is present (no leak); relay reasoning as a text block when
  the response is reasoning-only (no 502). requestedThinking === undefined
  keeps the legacy 'always a thinking block' relay.

chatCore.ts: pass hasActiveClaudeThinking(body) to the non-stream
translate call (inline, since the shared const is in the stream branch's
temporal dead zone here).

Tests: 25/25 (5 gate-restore, 1 gate-502-repro, 4 nonstream-leak,
15 resp-openai-to-claude incl. requestedThinking suppression regression).
Group E (22) + F (14) regression-free. typecheck:core clean.

* fix(sse): restore requestToolIdentityMap in the Codex CLI responses-translation path

The needsResponsesTranslation branch (openai-responses -> openai, used
when the client also speaks Responses) silently dropped the
requestToolIdentityMap argument to createSSETransformStreamWithLogger
when the requestedThinking parameter was added, reverting the #7936
tool-identity round-trip fix for that branch. The sibling
needsTranslation branch was updated correctly; restore the same
argument here.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs(changelog): add the 3 fragments documented in the PR body

The PR body already writes out the changelog.d/ entries for the DSML
parser (Group F), the directive-preamble stripper (Group E), and the
reasoning-gate thinking-leak fix (Group G), but none of the files
existed in the diff. Add them so the release aggregator picks them up.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(sse): realign GLM's positional call after the new requestedThinking parameter

createSSETransformStreamWithLogger gained a new requestedThinking
parameter inserted before customToolNames. glm.ts's translateSseResponse
still called it with the pre-existing positional argument list, so the
new parameter silently absorbed the old customToolNames slot, and the
GLM_STREAM_BUFFER_BYTES tuning value (#12925) landed on
requestToolIdentityMap instead of streamBufferBytes — a TS2345 (number
is not assignable to Map<...> | null) caught by
check:open-sse-typecheck, and a real loss of GLM's 64KB stream buffer
budget. Insert an explicit `undefined` for requestedThinking to restore
the original alignment.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(sse): make the system-preamble stripper opt-in and flush it at stream end

`createSystemPreambleStripper()` was wired DEFAULT-ON and unconditional in the
openai→claude streaming translator, unlike the exact-directive stripper right
above it, which only runs when the operator configured
OMNIROUTE_SYSTEM_INSTRUCTION_APPEND. Cause: the system-echo stripper recognises
its openers by English-prose heuristics ("# Skill usage", "# Verification
Process", <analysis>/<summary>/<system-reminder>), so leaving it always-on made
it mutate the response payload of EVERY openai→claude stream. A legitimate reply
opening with "# Skill usage: how to write one\n\nHere is the guide." lost that
whole section. It is now gated on OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1, mirroring
the directive stripper's opt-in.

Second cause, same feature: neither stripper was ever flushed. Both buffer while
a construct is still undecided — a directive prefix that never completes, an
<analysis> block that never closes — and nothing released that buffer at the end
of the stream. A reply consisting of an unterminated echo block therefore reached
the client as an EMPTY message: the answer was held in the buffer and discarded
with the stripper. Both strippers now expose flush(), the finish handler calls it
for both, and the released text is emitted as a text block. A construct that WAS
finally classified as an echo is not resurrected (the drop is final).

Tests: tests/unit/system-preamble-gate-and-flush.test.ts pins the default-off
contract, the opted-in behaviour, the flush for both strippers (unit + wiring),
and the no-resurrection guard. system-preamble-wiring.test.ts now opts in
explicitly, since it exercises the stripping path.

* fix(sse): thread the client's thinking intent into the non-streaming path

The streaming and non-streaming translators disagreed on the default meaning of
`requestedThinking`, so the SAME request produced different shapes depending on
`stream`. Cause: chatCore computes the client's intent
(hasActiveClaudeThinking) and threads it into the SSE translator, which relays
reasoning as a thinking block only when it is explicitly `true` — but NO caller
ever passed it to translateNonStreamingResponse(). The non-streaming
OpenAI→Claude conversion therefore only ever saw `undefined`, its legacy
"always relay a thinking block" default, and leaked reasoning to a client that
had opted out with `thinking: {"type":"disabled"}`. The streaming plumbing also
coerced an omitted value into an explicit `false`, hiding the divergence behind
two different spellings of "no intent".

Fix (least destructive of the options): do NOT flip either gate — both encode a
deliberate, regression-tested contract — but give the non-streaming path the
same input the streaming path already has. runNonStreamingProviderLeg owns the
client body (`sourceBody`), so it computes the intent with the very same helper
and passes it down through translateNonStreamingClientResponse. `undefined`
keeps its documented back-compat relay for callers that cannot express intent
(issue-7856 / issue-6623), and stream.ts no longer defaults the parameter to
`false`, so "absent" now means the same thing in both signatures.

No content is lost by the suppression: a reasoning-ONLY response is still
relayed as an ordinary text block (no empty response, no 502) — exactly what the
streaming finish handler does.

Tests: tests/unit/nonstream-requested-thinking-parity.test.ts drives the real
provider leg with thinking disabled / enabled / adaptive.

---------

Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 12:56:30 -03:00
Koosha Paridehpour
9c5d60027e fix(api): stream /api/logs/export with row cap to prevent V8 heap OOM (#13123) (#13428)
* fix(api): stream /api/logs/export with row cap to prevent V8 heap OOM (#13123)

Fixes #13123

GET /api/logs/export buffered every matching row into a single
JSON.stringify call with pretty-printing (null,2), roughly doubling the
string size. On tables with tens of thousands of rows this crashed the
Node process with a V8 heap OOM, taking the gateway down for minutes.

Changes:
- Stream the response via ReadableStream, serializing one row at a time
  so peak memory stays bounded regardless of table size.
- Add a configurable row cap (limit query param, default 10000, max
  50000) so callers cannot accidentally request unbounded exports.
- Remove pretty-printing (callers can pretty-print client-side).
- Include cap metadata (capped, limit, totalAvailable) when the cap
  fires so callers know they received a truncated result.
- Preserve backward-compatible response envelope: { count, hours, type,
  logs, ... }.

* fix(api): push the /api/logs/export row cap down into the DB layer (#13123)

The route-layer streaming + cap from the previous pass still called
exportCallLogsSince()/exportProxyLogsSince(), which hydrated and
buffered EVERY matching row (including rows beyond the limit) before
the cap was ever applied — peak V8 heap was essentially unchanged.

Adds countCallLogsSince()/countProxyLogsSince() (cheap COUNT(*), no row
hydration, used for totalAvailable) and iterateCallLogsSince()/
iterateProxyLogsSince() that bound the query with SQL LIMIT and
yield/hydrate one row at a time: a generator over a LIMIT-bounded id
list for call_logs, and fixed-size LIMIT/OFFSET pages for proxy_logs
(the shared SqliteAdapter only exposes run/get/all, not a `.iterate()`
cursor, so LIMIT/OFFSET pagination is the available cursor-equivalent
without widening that interface across all 4 driver adapters). The
route now streams from these instead, so the full matching row set is
never buffered.

Also moves capped/limit/totalAvailable into the response header instead
of only the trailer, so a client consuming the stream incrementally
learns about truncation before processing every row.

Rewrote the test to call the real route.GET handler against a seeded
test database instead of a local reimplementation of the stream
builder, so a regression in the route or its DB-layer delegates is
actually caught.

Documents the pre-existing (now more clearly load-bearing) breaking
change in a changelog fragment: `limit` defaults to 10,000 rows, so
exports that previously returned everything are silently truncated.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Koosha Pari <koosha@phenotype.ai>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 12:32:43 -03:00
initguru
36df9e505a fix(codex): fail fast and release per-account Responses WS leases (#12911)
* fix(codex): fail fast and release per-account Responses WS leases

* chore(changelog): add fragment for Codex WS lease fail-fast fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(codex): carry the reasoning-rule context through the leased WS path

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 10:56:01 -03:00
Diego Rodrigues de Sa e Souza
21772f40f3 fix(security): generate a random per-install CLI token salt (#13679) (#13909)
Both src/lib/machineToken.ts::getActiveSalt() and its mirror in
bin/cli/utils/cliToken.mjs derived the CLI/management bearer token as
HMAC-SHA256(raw machine-id, salt) with a checked-in literal default salt
("omniroute-cli-auth-v1"). Since /etc/machine-id is commonly world-readable,
any local user who never set OMNIROUTE_CLI_SALT could derive the same
bearer token as the server.

getActiveSalt() now generates a random 64-char-hex salt on first use and
persists it under <DATA_DIR>/cli-token-salt.json (falling back to the
literal only when neither the env override nor a persisted/writable salt
can be established). Both implementations use the same resolution order
and the same wx-flag create-race handling so the CLI and server keep
deriving the same token. OMNIROUTE_CLI_SALT stays the explicit operator
override, unchanged.

Regression test: tests/unit/machine-token-random-salt-13679.test.ts
2026-09-17 10:45:43 -03:00
Diego Rodrigues de Sa e Souza
de369fcc59 fix(security): container/Fly REQUIRE_API_KEY posture + free-tier usage leak (#13679) (#13911)
PR E of the #13679 insecure-defaults umbrella (items #6, #7; item #8 analyzed
as by-design, no change). The published Docker image and fly.toml shipped
without REQUIRE_API_KEY set, so a bare `docker run` (README/QUICK-START
one-liners, no --env-file) or a `fly deploy` combined "keyless" with
"world-reachable" for the anonymous /v1 LLM proxy. docker-compose.yml already
mitigates this via loopback-only binding (#12568) and correctly keeps
following the operator's own .env, so it is untouched. The npm/CLI
local-first REQUIRE_API_KEY=false default in featureFlagDefinitions.ts is
also untouched per the owner's decision.

/api/free-tier/summary ships an unconditional Access-Control-Allow-Origin: "*"
and always included the operator's own local usedThisMonth/remaining usage
regardless of auth — a low-severity info leak to any reachable origin. Both
fields are now withheld from unauthenticated callers while the intentionally
public catalog data stays served to everyone.

The gemini-SSE (openai-to-gemini-sse.ts) sub-finding needed no code change:
/v1beta/models/*:streamGenerateContent is already classified CLIENT_API and
fronted by clientApiPolicy through src/proxy.ts before the translator ever
runs, and its CORS-header echo was already hardened fail-closed by #12573.
REQUIRE_API_KEY=true (this PR's container/Fly default) closes the dependency
that finding cited. Added a locking regression test confirming this chain.

Regression tests:
- tests/unit/issue-13679-container-posture-require-api-key.test.ts
- tests/unit/issue-13679-free-tier-summary-usage-leak.test.ts
- tests/unit/issue-13679-gemini-sse-requires-api-key.test.ts (confirmation)

Refs #13679
2026-09-17 10:45:07 -03:00
initguru
3e080877f2 fix(sse): bound active streams without terminal events (#12913)
* fix(sse): bound active streams without terminal events

* fix(sse): derive the active-stream ceiling from the largest registered model budget

The watchdog is a hard lifetime cap that never resets on bytes, so a flat
15-minute default killed models the registry already allows to run for 20
minutes (the Codex entries declare timeoutMs: 1_200_000). The default is now
that maximum plus a one-minute margin, and a new test re-derives the maximum
from the registry so a future larger budget fails the gate instead of silently
re-opening the bug.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 10:44:48 -03:00
initguru
4c4d5c7fbe fix(resilience): allow maxWaitMs=0 as disable sentinel for execution expiration (#12902)
* fix(resilience): allow maxWaitMs=0 as disable sentinel for execution expiration

maxWaitMs normalization clamped the value to min:1, silently rewriting
an operator's 0 ("disable the limiter-managed execution deadline") into
1 — a 1ms expiration that killed every long-running job instantly. This
broke long-running reasoning models (GLM-5.2 with reasoning.effort=max
spends minutes before the first token, exceeding any practical
maxWaitMs; the TTB safety net is FETCH_TIMEOUT_MS, default 600s).

Fix: lower the floor to min:0 so 0 is preserved as the disable sentinel.
Issue #4165 follow-up.

Tests: 7/7 (resilience-normalize-maxwaitms-disable 5 + rate-limit-
maxwaitms-disable-execution 2). typecheck:core clean.

* fix(resilience): relax requestQueueSettingsSchema.maxWaitMs to allow 0

normalizeRequestQueueSettings already treats maxWaitMs=0 as an explicit
disable sentinel (queue-wait budget off), but the settings API schema
still rejected 0 with min(1), so an operator could never actually reach
the fix through PATCH /api/resilience. executionMaxWaitMs is untouched
(stays min(1) — separate field, separate decision, see #12902 item 4).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(resilience): prove maxWaitMs=0 vs #12715's queue-wait gate behavior

Answers the open technical question from #12902's review: does a
GLOBAL maxWaitMs=0 reintroduce the unbounded-queue regression #12715
fixed (a request hanging ~6min until the client aborts)?

Evidence, exercising the real gate chatCore.ts actually calls
(accountSemaphore.acquireMany({ timeoutMs: requestQueue.maxWaitMs }),
not the Bottleneck reservoir the PR's own tests cover) under real
contention (maxConcurrency=1, two concurrent acquires):

  - No: it does not hang. setTimeout(reject, 0) fires on the next
    tick, so a second contending request is rejected with
    SEMAPHORE_TIMEOUT in low milliseconds, never minutes.
  - But it is also not a genuine 'no cap' — an operator setting 0
    expecting 'wait as long as it takes' instead gets near-zero
    tolerance for even momentary contention on any configured
    concurrency gate (global/provider/account). This is a real
    asymmetry vs. the Bottleneck reservoir path (where 0 truly means
    unbounded) left for the maintainer to decide how to resolve —
    not something this pass can decide unilaterally.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 10:44:30 -03:00
Koosha Paridehpour
28557418db fix(proxy): add combo scope to fail-closed proxy guard (#13551)
* fix(proxy): add combo scope to fail-closed proxy guard (fixes #13469)

The hasBlockingProxyAssignment guard only checked account, provider, and
global scopes. Combo-scoped proxy assignments were not checked, so a fully
dead combo pool fell through to direct egress — leaking the host IP.

- Add combo scope to the SQL guard query
- Add optional comboName parameter to hasBlockingProxyAssignment
- A dead combo pool now blocks egress like the other three scopes

* fix(proxy): thread comboName through safeResolveProxy to the combo-scope guard (#13469)

hasBlockingProxyAssignment() gained a comboName parameter and a combo-scope
SQL clause, but its only caller, safeResolveProxy() in chatHelpers.ts, never
passed it — the clause always bound NULL and never matched a real combo
scope_id, so a fully dead combo-scoped proxy pool still fell through to
direct egress. Thread comboName from handleSingleModelChat (where it is
already in scope) through safeResolveProxy into the guard, and add tests
covering both the guard predicate and the end-to-end wiring.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Koosha Pari <koosha@phenotype.ai>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 10:44:13 -03:00
Koosha Paridehpour
2dae6df518 feat(sse): retry transient 5xx backend errors with jitter (global-fallback call site) (#12695) (#13143)
* feat(sse): retry transient 5xx backend errors with jitter (global-fallback call site) (#12695)

Per Diego's review feedback on the original PR (which was closed for base drift + over-broad scope):

- Helper in tests/unit/ (not open-sse/__tests__) - 7 node:test cases
- Drop 429 from retryable set (keep 502/503/504) - 429 must respect the
  RateLimit-Reset header and be surfaced, not silently retried
- Touch ONLY the global-fallback call site in chat.ts - the
  combo-target loop has its own retry semantics already

The wrap uses decorrelated full-jitter exponential backoff (per AWS
retry guidance), abort-aware (client disconnect cancels immediately),
and respects the AbortSignal via a sleep that throws on abort.

Re-cherry-pick: dropped all unrelated drift from the original PR
(videoBridgeLog plumbing, forcedConnectionId refactor,
comboCheckProvider/ghComboGate, getPassthroughProviders, Moonshot
quota fetcher, withSelectedConnectionHeader, classifyProviderBreakerResult,
reanchorVideoBridgeRedaction, etc.). chat.ts is now +20/-16 — purely the
import + the wrap around handleSingleModelChat in the global-fallback
block. Diego should now see a clean, focused diff.

Fixes #12695

* fix(sse): address kilo-code-bot review on transientBackendRetry helper

Per the review comment on the previous commit:
- Jitter formula now matches documented AWS decorrelated jitter
  (temp = min(cap, random(base, prev*3))) instead of an ad-hoc
  baseMs + rand*prev*2 approximation
- AbortSignal is honoured by the default sleep implementation (was
  previously only honoured by the loop's pre-attempt check)
- 'source' option added to TransientRetryOptions so callers can
  propagate an observability label (e.g. 'global-fallback') through
  onRetry

Adds 3 tests:
- default sleep respects AbortSignal without custom sleep
- onRetry receives source label
- decorrelated jitter is bounded by capMs

10/10 unit tests pass.

---------

Co-authored-by: KooshaPari <kooshapari@users.noreply.github.com>
2026-09-17 10:43:54 -03:00
Koosha Paridehpour
5455740faa fix(compression): log warnings for unreadable settings rows (#13522)
* fix(compression): log warnings for unreadable settings rows

getCompressionSettings() silently skipped non-string (BLOB) and
invalid-JSON settings rows, making it impossible to diagnose config
drift between the panel and the runtime.

Now logs a warn-level message for each unreadable row, including the
key name and a remediation hint (re-save from the Storage panel).

Also warns when the 'engines' row exists but yields no valid toggles,
so operators know their panel-configured engines map is being silently
replaced by the legacy fallback.

Fixes #13456

* test(compression): cover getCompressionSettings warnings for unreadable rows

The test for #13456 only asserted a stubbed console.warn recorded a
message and never called getCompressionSettings(), so it never
exercised the production change. Seed a BLOB row, an invalid-JSON row,
and an 'engines' row that isn't a usable object, and assert the
resulting warnings; also assert a legitimately empty (but valid)
'engines' map does not warn.

Also stop warning on a valid-but-empty 'engines' row: parseStoredEnginesMap
returns null both for an unreadable row and for a well-formed {} (an
operator who deliberately disabled every engine), so only warn when the
stored value isn't a usable object at all.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Koosha Pari <koosha@phenotype.ai>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 10:43:37 -03:00
Diego Rodrigues de Sa e Souza
cdcf1d2589 fix(i18n): translate the 3,719 __MISSING__ markers eight base PRs added on 2026-09-16 (#13974)
61 keys × 61 locales stamped as __MISSING__ by eight PRs on 2026-09-16 translated with sync-ui-keys --translate-markers; ratio gate green again on the release tip. ⚠️ base-red inherited: #12732
2026-09-17 09:09:02 -03:00
Diego Rodrigues de Sa e Souza
0d089e7e39 fix(quality): clear the release/v3.8.51 base-reds (#13947)
* fix(quality): clear the release/v3.8.51 base-reds

19 failing unit tests plus the API Route Typecheck and mutation-test-coverage
gates, all reproduced on the clean tip before touching anything.

Ten of the failures share one cause. #13452/#13798 made `*-compatible-*`
buildUrl() refuse a connection with no baseUrl instead of quietly defaulting to
the real OpenAI/Anthropic API — which would ship the operator's stored key to a
public third party. The guard is right; three fixtures still built those
connections unhydrated, and one of them put baseUrl at the top level of
credentials, where the chat path never reads it.

The rest:

- modelDiscovery.ts missed the VertexModelMetadataProvenance cast that its
  read-path twin in db/models/synced.ts already had — both written by #12471.
- A provider-test regexp carried raw 0x00/0x1f bytes, which makes git, GitHub
  and ripgrep treat the file as binary. Same character class, written
  with escapes instead of the bytes themselves.
- #13399 (Agnes AI China) adds "agnes-cn" + "agnescn": the only two provider
  prefixes since the count was last set (412 -> 414). Everything else added in
  that range is model ids.
- The free-tier budget card SVG was stale (443 -> 452 models); regenerated by
  its own script.
- Three new tests were missing from stryker.conf.json tap.testFiles, so the
  mutants they kill did not count.

Three guards asserted syntax rather than the invariant they protect, and broke
when the source legitimately changed. Each was re-expressed and then verified by
mutating the source back:

- #2331 required modelEffort to head the rawEffort chain; #13556 deliberately
  put the server-selected force rule first. The real invariant is relative —
  modelEffort outranks the defaults a client injects — and it still trips when
  explicitReasoning is moved ahead of it.
- The OAuth loopback guard matched the isLocalhost arm literally; #9944 added
  `&& !opts?.manualLoopback`. It now matches the arm whatever guards it, and
  still fails when the hint stops being built.
- The i18n scanner flagged dynamically-built keys — t("effort." + mode) reaches
  it as a literal prefix, never a string. It now accepts a prefix that resolves
  to a namespace holding messages, and still fails when the namespace is gone.

tests/unit/sse-auth.test.ts (#12080) expected a bare null where #13879 now
returns the key-policy diagnostic — the same sentinel shape the terminal-state
path has used since #12441. The assertion was rewritten to the constraint #12080
actually protects: nothing usable comes back and neither connection leaks. The
contract risk that remains — those sentinels are truthy, and executeWebSearch
treats any truthy value as a credential — is filed as #13945 rather than
widened into this PR.

Refs #13866

* fix(quality): clear the second wave of release/v3.8.51 base-reds

The tip moved 13 commits while the first pass was running and brought its own
reds. All reproduced locally on the merged tree first.

vitest 4.1.11 -> 5.0.0 in the #13661 development-group bump is a major, and
vitest 5 moved `vite` from a dependency to a peerDependency. This repo only ever
declared `vite` under `overrides`, which pins a version but installs nothing, so
`npm ci` stopped providing it and the Vitest job died at startup with
ERR_MODULE_NOT_FOUND. Declared as the devDependency it actually is — the same
^8.0.16 the override already pinned, and what @vitejs/plugin-react asks for as a
peer — and regenerated the lockfile: 684 lines added, none changed.

#12909 filtered a mapped array with `toolCall is JsonRecord`, but the element
type is the tool-call literal or null, and a predicate's type has to be
assignable to the parameter's (TS2677). Narrowed by the element's own type
instead; the literal still satisfies JsonRecord at the return.

#12906 added `|| result.errorCode === "empty_response"` to the stream-failure
condition and Prettier rewrapped it, so the #8928 probe — which located the
branch by an exact four-line string — stopped finding it. It now matches on what
the branch tests rather than how it is typeset, and still fails when the
eviction call is removed.

probe-7293 is the visible half of a real conflict, filed as #13948. #7293 merges
a mid-array system into index 0; #12908, landed later, demotes it to "user" in
place instead. Both target the same constraint and only one can win, and the
combination also reorders: the pre-translation hoist moves the turn forward
expecting it to stay a system message, then the demotion converts it where it
now sits, ahead of the conversation. Choosing between the two strategies is a
product call, not a base-red one, so the test was realigned to assert the half
that protects the caller — the instruction survives, as a user turn — and pins
the current ordering with a pointer to the issue, so the eventual decision shows
up as a deliberate test change instead of a silent regression.

Refs #13866, #13948

* fix(quality): allowlist vite, rebaseline tip growth, drop a dead import

Third pass on the release/v3.8.51 base-reds. Declaring `vite` in the previous
commit was correct but incomplete: check-deps is a human review point against
typosquatting, so a newly declared package has to be vouched for by name.
Recorded in dependency-allowlist.json with why it is needed — the official Vite
build tool, already pinned through overrides, and a required peer of both
vitest 5 and @vitejs/plugin-react. That also turns check-deps.test.ts green.

check-file-size went red on nine files. One is mine: sse-auth.test.ts grew when
the #12080 assertion was rewritten. Three of the four assertions I had added
were redundant with the strict deepEqual that follows them, so they are gone and
the file grows by 4 lines instead of 8; the cap absorbs the rest.

The other eight are production and test files this PR does not touch, grown by
other work and never rebaselined — which is the whole reason a base-red drain
exists. Each is attributed to the commit that grew it: #12906 (chat.ts,
chatHelpers.ts, proxyFetch.ts, stream.ts), #12904 + #12910 (chatCore.ts), and
batch_api.test.ts from the same wave. Two of them predate the wave entirely and
were already over cap on 3d5baf13 — imageGeneration.ts (#13748) and
roundRobinCombo.ts (#13776) — so they were base-reds hiding behind a gate that
only surfaced them once the tip was merged in. Both are recorded separately from
the wave so the history stays honest about when each cap actually moved.

Note for whoever reads the gate next: it counts one line more than `wc -l`,
since it measures split length rather than newlines.

Finally, #13290 replaced rmSync with cleanupTempDataDir in
zcode-executor.test.ts but left the import behind, which the frozen-warning
ESLint gate rejects. Removed.

Refs #13866
2026-09-17 05:48:40 -03:00
initguru
9bc7eb8fd2 fix(vision-bridge): nested tool_result images + provider-prefix credential check (#12903)
* fix(vision-bridge): extract/replace images nested inside tool_result content

Claude Code sends tool_result images as {type:"image",source:{base64}}
nested inside a tool_result's content array, not as top-level content
parts. The vision-bridge guardrail's extractImageParts filtered nested
hits out (!p.nested), so these images were silently dropped — a
text-only executor then received a request with no image and returned
HTTP 400.

Port the path-based nested extraction/replace fix:
- MediaPart gains a path field: the key/index chain from
  message.content[partIndex] down to the media object itself.
- inspect() tracks the path through recursion; pushPart stamps it.
- extractImageParts drops the !p.nested gate and emits path for nested
  hits (extract↔replace contract preserved: same order, every hit
  replaceable).
- replaceImageParts rewrites via detectMediaParts: top-level hits swap
  their content slot, nested hits walk MediaPart.path via the new
  replaceObjectAtPath helper.
- ensureBase64ImagesForClaudeWire skips nested hits (.filter(!p.path))
  to keep its sequential index map aligned.

TDD: 7 failing tests (path field, nested extract, nested replace,
document order) → 47/47 pass. typecheck:core clean.

* fix(vision-bridge): resolve provider prefix to node id for credential check

Re-land 932002580 (2026-08-19), which was never merged: it branched off
7acddd91a and fell outside the group-D reimplementation range (4f01fba68
re-picked only cf4dfc868). The same root cause now surfaces on the reroute
path (visionBridgeRerouteTextOnly=true): hasUsableCredentialsForModel
queried provider_connections with the bare node prefix "skhynix" → 0 rows
→ false → getBestVisionModel discarded the configured fixed model and
auto-selected cloudflare-playground/moonshotai/kimi-k2.7-code → Playwright
chromium missing → 502 on every image-bearing request.

- resolveProviderCredentialIds: literal prefix + prefix-index mapped node
  id (no-op dedup), composed after #10760's alias→canonical
  resolveProviderId.
- getPrefixToNode: 60s-cached getProviderPrefixIndex lookup, fail-open
  null.
- hasUsableCredentialsForModel: loop the resolved provider ids and return
  true when any has a usable active connection; noauth empty-set
  semantics (#10702) preserved.

TDD: resolveProviderCredentialIds 4/4 + skhynix node-id integration test
(RED confirmed: false !== true on the reroute regression). Focused
regression green: visionBridgeCredentials 10/10, vision-bridge reroute/
credentials suite 12/12, vision-bridge policy/mode/cache 18/18,
visionBridgeRouter 16/16. typecheck:core clean.

---------

Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 02:30:54 -03:00