Commit Graph

3518 Commits

Author SHA1 Message Date
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
initguru
d70f43d4b4 fix(sse): retry empty_response 502 + reasoning-aware direct response-start timeout (#12906)
* fix(sse): retry 0-byte empty_response 502 like STREAM_EARLY_EOF to stop autocompact 502

A genuine 0-byte upstream empty response (GLM-5.2 on a huge autocompact
context returns ONLY reasoning_content or nothing, then closes) reaches
stream.ts::emitClaudeEmptyStreamErrorAndAbort which emits a 502 with
code "empty_response" via the onFailure callback AND propagates the
failure down the pipeline as controller.error(new Error(msg)). The plain
Error carries no .code, so getUpstreamErrorIdentifier (reads only
error.code) returns undefined, result.errorCode/result.errorType become
undefined, and the single-model retry guard (chat.ts) only matches
errorType === "stream_early_eof" / errorCode === "STREAM_EARLY_EOF".
The 502 surfaces to the client with no re-attempt (call logs
1788132529140-96ef4a / 1788142914004-062cf6, ~48s, tokens out=0).

This is the same class of transient upstream glitch STREAM_EARLY_EOF was
built for (HTTP 200 then zero useful frames — #3758), but empty_response
was never wired into the retry path.

Fix (three chokepoints, all required for consistency):
- stream.ts: emitClaudeEmptyStreamErrorAndAbort now propagates an Error
  carrying code="empty_response" so a downstream classifier can identify
  it (plain new Error(msg) dropped it).
- chatHelpers.ts: shouldRetryStreamEarlyEof now treats "empty_response"
  the same as "STREAM_EARLY_EOF" via RETRYABLE_STREAM_EMPTY_CODES Set —
  ONE bounded same-connection re-attempt, never a loop
  (STREAM_EARLY_EOF_MAX_RETRIES=1 unchanged).
- chat.ts: the single-model retry guard now also enters on
  errorCode === "empty_response".

The bounded retry never marks the account unavailable (an empty response
is a transient upstream glitch, not a bad key), mirroring #3758.

Tests: 5/5 (stream-empty-response-retry-96ef4a). Existing 3758 regression
guard stays green (5/5). typecheck:core clean.

* fix(sse): make direct response-start timeout reasoning-aware to stop 504 on high-effort TTFB

Reasoning models (GLM-5.2/5.3 reasoning.effort=high/max, codex-gpt-5.x-high,
third-party Claude-format replicas) warm up with a ~78s+ TTFB before
emitting the first byte. The stream-readiness layer (streamReadinessPolicy)
already budgets 180s for this class, but the fetch-layer guard
(resolveDirectHeadersTimeoutMs) was a flat 30s — it pre-empted a warm
reasoning response the readiness layer would have permitted, surfacing a
504 (regression introduced by 142ae9349).

Fix: resolveDirectHeadersTimeoutMs now accepts the request body and, when
hasHighReasoningEffort(body) matches a quoted "reasoning_effort" or nested
"effort" field with value high/max, raises the budget to
REASONING_READINESS_CEILING_MS (180_000) — aligning to the same ceiling the
readiness layer uses. The operator env override (OMNIROUTE_DIRECT_HEADERS
TIMEOUT_MS) is treated as a FLOOR: reasoning awareness only raises the
budget, never lowers it; an override above the ceiling (e.g. 240s) is
preserved.

proxyFetch.ts passes the request body (when it is a string) to
resolveDirectHeadersTimeoutMs so the budget is per-request.

The HIGH_REASONING_EFFORT_PATTERN is a bounded, non-overlapping regex
(no variable-length quantifier overlap) — no ReDoS surface (PII rule #1).

Tests: 7/7 (direct-response-start-timeout-reasoning-504 — flat default,
env override, high/max ceiling bump, floor semantics, non-reasoning
pass-through). typecheck:core clean.

* docs(changelog): add fragments for empty_response 502 retry + reasoning-aware timeout

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 02:30:15 -03:00
Diego Rodrigues de Sa e Souza
3d5baf13f4 fix(providers): strip Vertex doc script blocks whose end tag carries junk (#13936)
The Vertex model-docs HTML is converted to plain text before the table
parser reads context-window and token-limit numbers out of the cells.
The script/style removal pass required the end tag to be `</script\s*>`,
but the HTML spec closes the element on `</script\t\n foo>` too. Such a
block survived the pass; the generic `<[^>]+>` strip below then removed
both tags and kept the script BODY, so text that only ever existed inside
a script became cell text the number parser trusts.

Accept any end tag that starts with `</script`/`</style` followed by a
tag-name boundary, matching what a browser does.

CodeQL js/bad-tag-filter, alert #1007.
2026-09-16 23:57:14 -03:00
Ravi Tharuma
416c736bb9 fix(oauth): soft-fail Claude refresh so CredentialHealth is not sticky-dead (#13185)
Merged after boarding with #13426 into one worktree cut from `release/v3.8.51` (both verified as ancestors of the combined HEAD before validating).

**Evidence**
- Your own test plus **every sibling** in the module — 14 files across `tokenHealthCheck*`, `token-health-check*`, `credential-health*` and `issue-13470-token-refresh-proxy-bypass`: **72/72 pass** on the combined tree. Running the siblings and not just the PR's own file is deliberate: this PR changes sweep-path state that several of those files exercise independently.
- Gates: `check-changelog-integrity` PASS, `check-complexity` PASS (2842 vs baseline 3218), `check-cognitive-complexity` PASS (1284 vs 1437), `typecheck:core` PASS.

**Reconciled — one real gate violation, fixed in your branch (d9164886)**

`check-file-size` genuinely tripped on this PR: `src/lib/tokenHealthCheck.ts` goes 1214 → 1221, past a frozen ceiling of 1218 that had only 4 lines of headroom. Attributed by measuring both sides, not assumed — the tip is at 1214 with no violation. Rebaselined the ceiling to 1221 with a dated justification key, since the growth *is* the fix: preserving the `refresh_token` and telling a transient failure apart from a dead credential needs extra state on the sweep path that cannot leave the module without breaking its internal API.

One thing that looked like your problem and is not, recorded so nobody re-raises it: `open-sse/executors/codex.ts: 1529 > 1528` shows up when the gate runs on your branch. Your branch carries an older merge of the release where that file was longer; the tip has it at 1524, your diff never touches it, and it does not survive the squash.

Thanks, @RaviTharuma — a sticky-dead `CredentialHealth` is the worst failure mode here, because a transient refresh blip permanently parks a working account and nothing ever retries it. Driving the real provider through two consecutive sweeps and re-reading the DB row in between is the right way to prove the state actually clears.
2026-09-16 23:10:24 -03:00
Nguyen Thanh Dat
5acac8021d fix(redis): namespace warmup circuit-breaker keys with REDIS_KEY_PREFIX (#13328)
The warmup scheduler's circuit-breaker keys were written to Redis without `REDIS_KEY_PREFIX`, so they escaped OmniRoute's namespace and could collide with another app sharing the instance — the one Redis surface the prefix wasn't reaching. Probe: 2/2 pass in `tests/unit/lib/warmupScheduler/redisCircuitBreakerStorePrefix.test.ts`, covering both the prefixed case and the unset/blank case where keys must stay unchanged.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.

**Reconciled** — this PR was `CONFLICTING`. The conflict was in `docs/reference/ENVIRONMENT.md` and purely additive: the release tip had inserted `APP_BIND_HOST` / `QDRANT_BIND_HOST` / `BIFROST_BIND_HOST` rows directly above the `REDIS_KEY_PREFIX` row you edited. Kept both sides — the tip's three new rows and your updated description naming the warmup circuit breaker — then merged the current release branch in (120a92f6) and re-ran your focused test on the reconciled tree: 2/2 pass. No line of your diff was dropped.

Thanks, @datrixlab — you also updated `.env.example`, `docs/ops/REDIS_PRODUCTION_CONFIG.md` and `ENVIRONMENT.md` alongside the code, which is why the only thing left to do here was a mechanical conflict resolution.
2026-09-16 21:09:38 -03:00
Nguyen Thanh Dat
c1338f1e78 fix(translator): pair id-less Gemini tool results with their call (#13334)
Gemini tool results without an id could not be paired with their originating call, so the pairing fell apart on any history that omitted ids. Probe on your head: 24/24 pass across `gemini-tool-result-without-id` and the existing `v1beta-gemini-tool-calling-6222` suite; the combined run reconfirmed both plus the antigravity path.

Thanks, @datrixlab — extracting `geminiToolCallIds.ts` as a shared helper instead of duplicating the pairing logic across `gemini-to-openai`, `antigravity-to-openai` and the v1beta converter is what keeps the three from drifting apart later.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
2026-09-16 21:03:40 -03:00
Nguyen Thanh Dat
53e3e23c13 fix(api): stop DISABLE_SQLITE_AUTO_BACKUP from turning off Redis rate limiting (#13329)
A backup flag was being used as a proxy for test mode, so `DISABLE_SQLITE_AUTO_BACKUP` also disabled Redis rate limiting — two unrelated concerns riding one variable. Probe on your head: 3/3 + 13/13 pass across the new test and the existing rate-limiter suite.

Thanks, @datrixlab — catching that the existing rate-limiter tests still pass is what shows this untangled the two without changing the intended behavior of either.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
2026-09-16 21:03:09 -03:00
Nguyen Thanh Dat
bb45fb8ede fix(providers): let a dashboard OFF for private provider URLs beat the env opt-in (#13323)
Confirmed on the tip: `src/shared/network/outboundUrlGuardPolicy.ts` only checked `isTrueValue(dbValue)`, so an explicit dashboard OFF fell through to the env opt-in instead of overriding it — the operator turning something off in the UI had no effect. Probe on your head: 7/7 pass.

Thanks, @datrixlab — an explicit OFF in the UI losing to an env var is the kind of thing that erodes trust in the whole settings surface.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
2026-09-16 21:02:35 -03:00
Nguyen Thanh Dat
3bcb181cc2 fix(quota): apply the equal-split fallback in the pool usage snapshot (#13159)
The equal-split fallback was applied when computing allocations but not when building the pool usage snapshot, so `sqliteQuotaStore.ts:201` still read `totalWeight > 0 ? alloc.weight : 0` — a zero-weight pool reported every member at 0 instead of its equal share. Probe on your head: 9/9 pass in `tests/unit/quota-pool-usage-equal-split.test.ts`, with the bug confirmed unfixed on the tip.

Thanks, @datrixlab — fixing the snapshot path and not just the allocation path is the part that makes the dashboard numbers agree with the enforcement.

**Batch validation** — boarded with the other 10 PRs of your batch into one worktree cut from `release/v3.8.51`; every PR verified as an ancestor of the combined HEAD before validating.

- Focused tests across all 11 PRs: **104/104 pass** on the combined tree.
- Gates on the combined tree: `check-changelog-integrity` PASS, `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check:open-sse-typecheck` PASS.
- `check-file-size` is red, but reproduces with byte-identical line counts on the pure `release/v3.8.51` tip (`open-sse/handlers/imageGeneration.ts` 3304, `open-sse/services/combo/roundRobinCombo.ts` 1221, `open-sse/utils/stream.ts` 3115). Inherited base-red, nothing added by this batch — it is also why this PR's "Fast Quality Gates" check was red.
2026-09-16 21:01:24 -03:00
Dmitry Kuznetsov
00860f3278 fix(antigravity): rotate image accounts on explicit quota exhaustion (#9908)
Merged after batch validation on a combined worktree cut from `release/v3.8.51` with #9944 and #7138.

**Evidence**
- Focused tests: 36/36 pass on the combined tree, including your 4 classification-boundary cases in `tests/unit/antigravity-image-credential-retry.test.ts` (Antigravity quota-exhausted `429` rotates; generic `RESOURCE_EXHAUSTED`, ordinary image rate-limit and non-Antigravity `429` stay terminal).
- Gates on the combined tree: `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check-changelog-integrity` PASS.
- The red `check-file-size` reproduces byte-identical on the pure `release/v3.8.51` tip — inherited base-red, not from this PR. The red CI run on this PR dates from 2026-09-15 against an older base.

**Related dispositions**
- #8053 is being closed in your favour: it chased the same account-rotation goal across 3 files plus a new `routingInstrumentation.ts`, while its `AbortSignal` half was already superseded on the tip by independent work. This PR does the same job in 31 lines of production code by reusing the existing `classify429` engine.

Thanks, @Ardem2025 — the deliberate narrowness here is the reason this merged and the bigger version didn't. Gating rotation on `provider === "antigravity" && status === 429 && classify429() === "quota_exhausted"` keeps non-idempotent image generation from being retried on ordinary rate limits, and you proved each negative case rather than just the happy path.
2026-09-16 19:59:43 -03:00
Dmitry Kuznetsov
2f0a01d75c fix(oauth): expose manual Codex callback entry (#9944)
Merged after batch validation on a combined worktree cut from `release/v3.8.51` with #9908 and #7138.

**Evidence**
- Focused tests: 36/36 pass on the combined tree (`oauth-modal-codex-lan-ip-8046`, `antigravity-image-credential-retry`, `antigravity-usage-service`, `generic-quota-fetcher`), including the 2 pre-existing anchor tests that assert `codex` stays in `PKCE_CALLBACK_SERVER_PROVIDERS` and that the `localhost:1455` redirect URI is untouched.
- Gates on the combined tree: `check-complexity` PASS, `check-cognitive-complexity` PASS, `typecheck:core` PASS, `check-changelog-integrity` PASS.
- `check-file-size` is red, but reproduces byte-identical on the pure `release/v3.8.51` tip (`imageGeneration.ts`, `roundRobinCombo.ts`, `stream.ts`) — inherited base-red, not from this PR.

**Reconciled**
- `changelog.d/fixes/codex-manual-loopback-action.md` did not start with a markdown bullet, which is the one thing `check-changelog-integrity` failed on. Fixed in your branch (d3dbb5b) so the fragment convention holds; nothing else in your diff was touched.

Thanks for this one, @Ardem2025 — exposing the manual callback entry that already existed in the code instead of adding a new flow is exactly the right shape for the LAN/remote case, and keeping every PKCE/state check untouched made it easy to verify.
2026-09-16 19:59:25 -03:00
Jan Leon
f1e7148c19 fix(routing): preserve reasoning overrides across transports and fallbacks (#13556)
Merged. The failure mode was concrete — a matched reasoning rule dropped on native Responses/Anthropic paths, model-suffix/account defaults, or fallback preparation, and `_omnirouteReasoningRule` leaking upstream as `Unsupported parameter` — and the fix is carried in the request-local credential context through dispatch, refreshed credentials and fallbacks, with forced effort winning over defaults and client-forged markers dropped at ingress. The 11-case integration suite exercises the real routing/translation modules.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you — and for keeping this a runtime-only change with the editor and service-tier work in their own PRs.
2026-09-16 17:01:51 -03:00
Jan Leon
a928ea8762 fix(models): reconcile provider dashboards with active live catalogs (#13434)
Merged. The NVIDIA 116-vs-82 discrepancy is the visible symptom; the fix is the right one — reuse the existing `liveCatalogAuthoritative` policy on the dashboard listing instead of a provider-specific filter, refresh after a removals-only import, keep the last confirmed snapshot on a failed refresh, and apply the same membership rule to the OpenRouter/compatible/passthrough row builders so static fallbacks cannot resurrect retired rows. Operator custom models and overrides preserved.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you — checking the projection against 14 production catalog snapshots is the kind of evidence that makes a listing change safe to land.
2026-09-16 16:56:28 -03:00
Jan Leon
bd8a12f304 fix(providers): fetch live Qwen and Alibaba Token Plan catalogs (#13299)
Merged. Both Token Plan providers had no `modelsUrl` and no discovery config, so Sync Models never even tried a live request. Fetching the public Personal Plan catalog through `safeOutboundFetch` with fixed hosts, no inference keys and no cookies, validating the gateway envelope and reusing the DashScope text-model classifier keeps this narrow and safe; a failed or media-only result falls back without touching the previous catalog.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you.
2026-09-16 16:50:42 -03:00
Diego Rodrigues de Sa e Souza
af2002a493 chore: reconcile the JxnLexn merge wave with the release tip (#13921)
Lands the combined-board reconciliation of today's JxnLexn wave as one follow-up: i18n fill for #12471/#13555's new keys (real vi translations), free-tier count 446→452, file-size rebaseline for #13556. check-new-key-coverage PASS; only the three pre-existing file-size reds remain.
2026-09-16 16:43:44 -03:00
Jan Leon
a16705344e feat(dashboard): add a dedicated API-key routing editor (#13555)
Merged. Moving rule editing out of the permissions modal into `/dashboard/api-manager/routing` fixes the real problem (a cramped modal for something with conditions, effects and three target kinds), and the contract tests pin what matters: persisted fields round-trip, combo names match without rewriting off-catalog IDs, failed saves stay editable, unsaved drafts survive key switches, and writes are disabled after a failed load. Rule evaluation and authorization are untouched.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you.
2026-09-16 16:38:04 -03:00
Jan Leon
502e614850 fix(vertex): discover and route partner models correctly (#12471)
Merged. Root cause first: `publishers.models.list` was called with `pageSize=1000` against Google's hard maximum of 300, so every publisher answered 400 and discovery silently fell back to the stale static catalog. On top of that the PR separates API-key from Service-Account capabilities correctly (keys cannot list Model Garden — project-scoped curated catalog; SA tokens can — live catalog), stops treating the expected generativelanguage rejection of a Service Account as a discovery failure, replaces the speculative partner IDs with documented MaaS IDs, and rejects OAuth client-config JSON with a clear message instead of a misleading one.

The 5 ESLint errors flagged during the earlier fix sweep were fixed in your own follow-up commits; the branch was reconciled with the release tip and the 42 locale files were checked for lost keys before this merge.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you — the credential-capability distinction and the retired/non-chat filtering are what make the Vertex listing trustworthy.
2026-09-16 16:37:41 -03:00
Markus Hartung
1cf8e4bcc6 fix(db): auto-clean terminal batch checkpoints and expired file content (#12999)
Merged after a maintainer rework that kept every one of @hartmark's commits intact.

**What the rework added:** the auto-clean of terminal batch checkpoints and expired file content is gated behind a default-off feature flag (`BATCH_AND_FILE_AUTO_CLEANUP_ENABLED`, `defaultValue: "false"`, documented in `docs/reference/FEATURE_FLAGS.md` and described in all 66 locales) so the release default keeps today's behaviour and operators opt in; the DB handle leak in the test was fixed so the Node runner exits cleanly.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). 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.

Thank you — the cleanup itself is exactly the kind of maintenance that stops a data dir from growing forever.
2026-09-16 15:45:34 -03:00
Patryk Kopyciński
23c5772ccb feat: adaptive reasoning effort (auto) — gateway-resolved, per-turn pinned, all harnesses (#13448)
Merged after a maintainer rework that kept every one of @patrykkopycinski's commits intact — including the two refactors you pushed later (extracting the adaptive-effort wiring out of `chatCore.ts` and reading `x-omniroute-effort` inside the wiring module), which were merged into the rework rather than overwritten.

**What the rework added:** the adaptive-effort wiring is scoped to OpenAI-dispatch requests only (the claim in `docs/routing` was corrected to match), and `defaultReasoningEffort` was widened to accept `auto` explicitly instead of relying on a loose string.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). 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.

Thank you — gateway-resolved, per-turn pinned effort is a real feature, and the header contract makes it usable from every harness.
2026-09-16 15:18:36 -03:00
Patryk Kopyciński
421d1ff912 fix(devin): fall back to CLI probe when the HTTP API rejects a CLI-format key (#13617)
Merged after a maintainer rework that kept every one of @patrykkopycinski's commits intact, including the changelog fragment you added afterwards.

**What the rework added:** the `eslint-suppressions.json` diff was corrected (the PR had dropped live entries) and a test now proves the CLI-probe fallback path is actually taken when the HTTP API rejects a CLI-format key — before, the fallback existed but nothing exercised it.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). 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.

Thank you.
2026-09-16 15:15:16 -03:00