Commit Graph

1620 Commits

Author SHA1 Message Date
Domenico Massafra
fbe195d69e fix(opencode): preserve catalog display names (#13168)
* fix(opencode): preserve catalog display names

* test(cli): cover OpenCode catalog display-name precedence

Adds the automated unit test the PR body's manual smoke check
(Auto Chat / DeepSeek V4 Pro) was standing in for, covering all four
name-precedence branches: existing custom name, catalog display_name,
native catalog name with owned_by prefix stripped, and the auto/* readable
fallback. Also adds the changelog.d/fixes/ fragment.

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

---------

Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:24:03 -03:00
KeelTrace
95d3b164a5 fix(models): preserve free-model metadata from discovery (#12763)
* fix(models): preserve live free economics in synced discovery

* docs(changelog): add fragment for free-model metadata discovery fix

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:22:26 -03:00
Kareem Jalal
ad633c8440 fix(memory): word/sentence-boundary aware fact truncation (#12383)
* fix(memory): word/sentence-boundary aware truncation in extraction

sanitizeMatch() and capExtractionText() previously did raw character-offset
slices (slice(0, MAX_FACT_LENGTH) / slice(-MAX_EXTRACTION_TEXT_LENGTH)) with
no boundary awareness, producing garbled mid-word/mid-clause fragments that
get injected into LLM context as memory facts.

- sanitizeMatch() now backs the cut off to the nearest sentence-ending
  punctuation (. ! ?) within a lookback window, falling back to a plain
  whitespace boundary, falling back to the original hard cut only when no
  boundary exists nearby.
- capExtractionText() applies the equivalent boundary-aware trim on the
  front edge of the kept tail.

Mirrors the boundary-aware truncation pattern already used by
open-sse/services/compression/lite.ts (#8169) for tool-result truncation.

Adds tests/unit/memory-extraction-boundary-truncation.test.ts covering
word-boundary cuts, sentence-boundary preference, short-string passthrough,
the no-boundary-available fallback, and capExtractionText's tail behavior.

* docs(changelog): add fragment for word/sentence-boundary fact truncation

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:10 -03:00
Paijo
023a57476f feat(chat-admission): expose admission tunables via dashboard settings (#12038)
* feat(chat-admission): add settings store for admission tunables

* fix(chat-admission): extract parseEnvNumber to reduce cyclomatic complexity

* fix(chat-admission): repair settings store write path and add coverage

The settings store could not persist anything: `updateChatAdmissionSettings`
targeted an `updated_at` column that `key_value` does not have (the schema is
namespace/key/value — src/lib/db/core.ts), so every write threw
`table key_value has no column named updated_at`.

Also fixes, found while adding the tests:

- `getChatAdmissionSettingsSource` returned a partial map (only the keys whose
  layer differed from the default) and dropped the unset keys entirely, so a
  dashboard reading it could not render a complete row.
- env parsing used `parseFloat` for the shed ratio, so `"0.5x"` was silently
  accepted as 0.5 while `chatBodyAdmission.ts` rejects that same input — both
  paths now share one per-field predicate table.
- DB reads validated `typeof === "number"` but not integrality/range, so a
  hand-edited row could serve `2.5` or `-1` to the admission controller.
- writes persisted unvalidated input.
- malformed, non-object, and partial rows are now tolerated per field.

Adds tests/unit/db-chat-admission-settings.test.ts (17 cases) covering CRUD
round-trips, namespace isolation, reset, env parsing/validation boundaries,
env-over-DB precedence, provenance, normalization on write, and malformed-row
tolerance, per Hard Rule #8.

Verification: eslint clean; `npm run typecheck:core` clean; the new suite plus
the two sibling settings suites pass 63/63; check-complexity-ratchets reports
complexityNewCode=0; check-db-rules OK; check-env-doc-sync OK (all three vars
are already documented in .env.example).

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
2026-09-18 12:20:53 -03:00
voidstack
c07cebbab7 fix(db): close failed initialization connections (#13342)
* fix(db): close failed initialization connections

* docs: add changelog fragment for #13303 db handle-leak fix

---------

Co-authored-by: voidstackloop <voidstackloop@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:00:16 -03:00
Amirreza Kimiyaei
04cc8aab67 fix(cache): fold the response output contract into the semantic cache signature (#12309)
* fix(cache): fold the response output contract into the semantic cache signature

The signature hashed only {model, messages, temperature, top_p}, so two temp=0
requests with identical messages but different response_format shared a cache
key: the second was served the first's stored body under a 200, violating the
schema it asked for. tools/tool_choice had the same exposure.

generateSignature now takes an optional output contract — response_format,
text.format, tools, tool_choice, collected by outputContractOf() — and folds it
into the digest only when present, so plain-chat signatures (and every cache
entry already written for them) are unchanged. All three call sites pass it;
read/write symmetry is preserved because bodyForCacheWrite snapshots the same
body object the read path hashed (#cache-signature-asymmetry).

Closes #12307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): add fragment for #12307 semantic-cache output-contract fix

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

* fix(cache): populate both constraint spellings in outputContractOf

The merge with #12734 left generateSignature reading the camelCase
constraints (toolChoice/responseFormat) with a snake_case fallback, but
outputContractOf only filled the snake_case keys, so the #12734
"signature is called with tool_choice/tools/response_format from body"
store tests failed on the merged branch. Set both spellings so either
caller shape reads the value it expects.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: amirrezakm <amirrezakm@users.noreply.github.com>
2026-09-18 11:59:50 -03:00
ducphamtien-fonos
128f06d645 fix(translator): support Responses custom tool choice (#13128)
* fix(translator): support Responses custom tool choice

* fix(translator): preserve custom tools across response paths

* docs(changelog): add fragment for Responses custom tool choice fix

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

---------

Co-authored-by: Pham Tien Duc <phamtienduceng@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: ducphamtien-fonos <ducphamtien-fonos@users.noreply.github.com>
2026-09-18 11:59:24 -03:00
James
3ebea07278 fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients (#13031)
* fix(sse): replay reasoning for Responses-API targets on plain turns and Anthropic clients

DeepSeek thinking mode requires the reasoning of every prior assistant turn
to be passed back once the request carries `tools`, including turns that
made no tool call. Since #10540 routed opencode-go/deepseek-v4-* to
`/responses`, the reasoning replay cache had two gaps on Responses-API
targets, and clients that drop `reasoning_content` hit intermittent
`400 The reasoning_text in the thinking mode must be passed back`.

1. Plain (non-tool-call) turns are keyed on a digest of the normalized
   OpenAI transcript. Both capture sites used `translatedBody.messages` as
   the history, which a Responses body (`input`) does not carry, so the
   write-time digest never matched the read side. translateRequest now
   reports the pivot transcript it digested via `onReasoningReplayHistory`,
   and the streaming / non-streaming capture sites digest that transcript.
2. The Responses replay pass was gated on `sourceFormat === "openai"`, so
   Anthropic Messages clients (Claude -> OpenAI -> Responses) got no replay
   at all. The pass now runs on the OpenAI pivot for every source format,
   right before the Responses conversion discards `messages`.

The reported transcript is a shallow snapshot of the digested fields only
and travels through a callback, not the body, so nothing new reaches the
upstream payload.

* docs(changelog): add fragment for #13031

* fix(sse): guard the Responses capture sites and skip plain-turn writes with no history

Review follow-ups for #13031:

- Add tests/unit/chatcore-reasoning-cache-write-guard-responses.test.ts:
  runs the real handleChatCore against a mocked opencode-go/deepseek-v4-flash
  Responses upstream (JSON and SSE), then asserts the next turn's upstream
  body carries the replayed `reasoning` input item. Removing either capture
  site fallback turns both cases red.
- Project the reported transcript down to the digested fields only
  (tool_calls keep type/name/arguments, ids are dropped) and document that
  `content` is shared by reference.
- Skip the plain-turn cache write when the history is empty: a real request
  always has a prior user turn, so an empty history means the transcript
  could not be recovered and a one-message digest can never match.
- Changelog wording: the pre-fix write digested only the assistant message.

* test(sse): select the /responses dispatch by URL in the Responses replay guard

Review follow-ups for #13031: the guard picks the upstream body by URL
(`/responses`) and asserts exactly one such dispatch per turn instead of
taking the last fetch, the streaming case asserts the same body shape as the
non-streaming one, and the `historyMessages` doc on
NonStreamingClientTranslateInput names the Responses-shaped fallback.

* docs(routing): name the replay-history hand-off without tripping the hook heuristic

The fabricated-docs gate treats any `onXxx` token in prose as a plugin hook
name and flagged `onReasoningReplayHistory` (a translateRequest option, not a
hook). Point at the option's home file instead.

* chore(quality): freeze chatCore.ts at 6159 for the Responses replay wiring

check:file-size in PR mode caps a frozen file at max(frozen, base). The rebase onto
the v3.8.51 tip (cde49c937) leaves chatCore.ts at 6159 lines against a 6146 ceiling:
the onReasoningReplayHistory callback on both Responses-capable translateRequest call
sites, reasoningReplayHistory on both non-streaming leg inputs, and the historyMessages
fallback at the streaming cache write. Record the growth with a justification key, as
#13033 did for the same file.

---------

Co-authored-by: jmche <jmche@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:59:06 -03:00
Wu Shuwen
10bb627576 fix(evals): mark eval-runner requests as self-managed so cases measure the model (#13139) (#13206)
* fix(evals): mark eval-runner requests as self-managed so cases measure the model

executeEvalCase() built its request with only Content-Type and Authorization, so
every graded case picked up the chat path's contextual injections: a selected
output style was prepended as a system message (gated on
`x-omniroute-compression`) and, once the request carried an API key, retrieved
memory plus the built-in `memory_*` tools were appended (gated on
`x-omniroute-no-memory`). An evaluation therefore measured the operator's
injected context as much as the model, and passing an API key to a run made its
score worse, because the key is what gives the request a memory owner (Refs #13139).

Both are documented request-header opt-outs, so the runner now sets them on every
case. Request construction moves to an exported buildEvalCaseRequest() so the
header contract is testable without invoking the chat route.

* docs(changelog): add the eval-runner self-managed-context fragment (#13206)

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:30 -03:00
IAMBOBJIM
d1b26bcb62 fix(sse): aggregate findInsensitive collision warning into one line per build (#12972)
modelMetadataRegistry's findInsensitive() warned once per colliding key while
building its lowercase index. On a real catalog that is hundreds of lines per
rebuild: a production log carried 27,296 of these in a single file — 40% of all
lines, in ~500/sec bursts — driving 52 MB log rotations and ~466 MB of logs on
disk.

The warning itself is worth keeping: a case-insensitive collision is a genuine
upstream data-quality signal (models.dev returning both "OpenAI" and "openai"
as distinct provider keys), and first-match-wins silently discards the later
value. Only the volume was wrong.

Collisions are now collected during the index build and reported as a single
line carrying the total count plus the first 5 keys, so the diagnostic survives
at 1/N the volume. No behavior change: the index, the first-match-wins
resolution, and the WeakMap identity cache are untouched.

Validated by TDD (Hard Rule #18): tests/unit/model-metadata-registry-collision-log.test.ts
fails on the old implementation (3 collisions -> 3 warnings, 50 -> 50) and
passes after (always 1). Also covers the no-collision case emitting nothing,
and asserts the aggregated line still names colliding keys.

Note for reviewers: the test fixture deliberately spells the provider key
"OpenAI" rather than "openai". findInsensitive short-circuits on
`if (key in obj) return obj[key]` before the index is ever built, so a fixture
containing the literal lookup key produces zero warnings and proves nothing.

Gates: eslint clean on both changed files. typecheck:core reports 9 pre-existing
errors in open-sse/services/compression/omniglyph* — unrelated to this change
(those files are byte-identical to origin/release/v3.8.50) and caused by a local
stale node_modules carrying omniglyph 1.3.1 against the required ^1.4.0.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:14 -03:00
Pixma
01b2467d61 feat(sse): add LLM Gateway DevPass quota tracking (#12462)
* feat(sse): add LLM Gateway DevPass quota tracking

Surface the LLM Gateway DevPass allowance (GET /v1/key) in OmniRoute's
quota telemetry, mirroring the OpenRouter API-key fetcher pattern.

- llmgatewayQuotaFetcher.ts: fetch + parse the DevPass /v1/key response
  (decimal-string USD values), exposing two windows — monthly plan
  credits and the 7-day premium-model window — with a 45s TTL cache.
  Pay-as-you-go keys (devPlan "none") and 401/403 fail open (no quota).
- Register in chat.ts before registerGenericQuotaFetchers + register the
  named windows for the dashboard cutoff modal.
- usage/llmgateway.ts leaf + usage.ts dispatch case so the Limits page
  renders the monthly + weekly premium rows.
- Add "llmgateway" to USAGE_FETCHER_PROVIDERS, USAGE_SUPPORTED_PROVIDERS,
  PROVIDER_LIMITS_APIKEY_PROVIDERS, and the dashboard label/order map.
- tests: 21 cases covering the parser, auth fail-open, cache TTL, window
  exhaustion, preflight proceed/block, registration, and the usage leaf.

* docs(sse): add changelog fragment + codebase-doc entry for llmgateway quota

* refactor(sse): register llmgateway quota via quotaTrackersBatch

Move the LLM Gateway fetcher registration out of chat.ts (a frozen
file-size-baseline chokepoint) into quotaTrackersBatch.ts, the dedicated
side-effect module that exists precisely so new fetchers don't grow
chat.ts. The batch import runs at module load, before
registerGenericQuotaFetchers(), so the bespoke fetcher still wins over
the generic path. Fixes the file-size gate (chat.ts must not grow).

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:05 -03:00
Diego Rodrigues de Sa e Souza
f3ab24b8c7 fix(db): rotate proxy pools on the chat path like the registry does (#13575) (#14044)
resolveProxyForConnection cached a scope pool's first resolution result for the
life of the per-connection cache, so a chat-path request never saw the pool's
round-robin/sticky/random strategy advance again — only the narrow #13578
set-aside escape hatch could break the freeze. resolveProxyForScopeFromRegistry
(used directly by every existing rotation test) always re-ran the strategy and
rotated correctly.

The cache now treats a registry-sourced pool result as due for re-resolution on
every call (falling through to the same cascade the direct registry callers
use), except for the two populations that need a stable egress across
requests: EGRESS_BUCKETED_LOCK_PROVIDERS (opencode's quota is bucketed by
egress IP) and grok-web (its cf_clearance cookie is pinned to the IP/UA/TLS
fingerprint that earned it).

Regression test: tests/unit/proxy-pool-chat-path-rotation-13575.test.ts, RED
before the fix (resolveProxyForConnection returned the same host 6/6 times for
a 3-member pool), GREEN after. Updated tests/unit/proxy-pool-skips-refused-member.test.ts's
three assertions that encoded the frozen-cache contract to the corrected
always-rotates-except-pinned contract; all other cases in that file and in
tests/unit/proxy-pool-rotation-6365.test.ts pass unchanged.
2026-09-18 11:57:32 -03:00
Diego Rodrigues de Sa e Souza
0d31fd3d24 fix(quota): resolve plan from pool's primary connection (#13876) (#14042)
Multi-connection Quota Sharing pools resolve a DIFFERENT provider plan
depending on which member connection actually served a request (write
path, enforceQuotaShare/recordConsumption) vs. the pool's primary
connection (dashboard read path, /api/quota/pools/[id]/usage). The
wizard's "Limite" step PUTs a manual plan override only to the primary
connection, so any other pool member fell back to a different
(catalog/empty) plan shape. Since the quota_consumption dimension key
is poolId:unit:window, a different unit/window meant recordConsumption
wrote to a bucket the dashboard never read, so real traffic served via
a non-primary connection never appeared as "consumed".

Fix: resolve the plan from the pool's canonical primary connection
(pool.connectionId) in both enforceQuotaShare and recordConsumption,
matching the dashboard's read path. recordConsumption now keeps the
matched pool object (not just its id) so it can reach connectionId.
getSaturation(input.connectionId, ...) is untouched — that signal is
legitimately per-connection.
2026-09-18 11:57:17 -03:00
Diego Rodrigues de Sa e Souza
a96c8381f7 fix(db): serve getPricingForModel() from the pricing cache (#13891) (#14040)
getPricingForModel() called the uncached getPricing() on every
invocation instead of the existing getCachedPricing() helper
(30s TTL, readCache.ts), so usageStats.getUsageStats() re-ran a
3-SELECT + JSON.parse + merge cycle against key_value once per
GROUP BY row -- up to 531 times on a large usage_history table --
blocking the event loop for several seconds on /api/usage/history.

Every known pricing writer (updatePricing, LiteLLM/models.dev sync)
already invalidates this cache via touchPricing()/invalidateDbCache,
so a write remains immediately visible; added a regression test that
proves both the cache hit path and the invalidation path.
2026-09-18 11:57:00 -03:00
Diego Rodrigues de Sa e Souza
b14ef5c7e5 fix(guardrails): resolve nested combo-ref hops before vision-bridge decision (#13927) (#14038)
getComboVisionBridgeDecision() treated any top-level combo-ref step as an
unconditional "process", without ever resolving the referenced combo's real
leaf models. A pass-through combo whose only member is a combo-ref to an
all-vision-capable inner combo was wrongly routed through the
describe-and-replace path, and with no describer model configured every image
was replaced with the literal stub text.

Recursively resolve combo-ref steps to their real leaf models (depth-guarded
by the same MAX_COMBO_DEPTH used by the flatten dispatch path, plus a
visited-set cycle guard) and fold their vision capability into the same
accumulation used for direct model steps. An unresolvable combo-ref (not
found / empty / circular / depth-exceeded) is conservatively treated as a
single non-vision-capable leaf instead of forcing the whole combo to
"process".
2026-09-18 11:56:42 -03:00
Diego Rodrigues de Sa e Souza
3b535968c4 fix(providers): detect Lemonade labels[] vision capability (#13918) (#14023)
detectVisionInput() only recognized supportsVision, architecture.input_modalities,
top-level input_modalities, and architecture/modality string shapes. Lemonade
Server's GET /v1/models exposes capabilities only through a labels[] string
array (e.g. ["chat", "vision", "reasoning", "tool-calling"]), so a
vision-labelled Lemonade model imported with supportsVision unset and was
advertised as text-only.

Add a fifth branch that does a case-insensitive, trimmed EXACT membership
test for "vision" in record.labels[] (not a substring match, per the prior
false-positive lesson with bare gemma id-fragment matching). Purely additive
- all four existing shapes stay byte-identical, proven by a new regression
test that exercises the architecture.modality path unchanged.
2026-09-18 11:55:54 -03:00
Dizzle
fc6b4587ad feat(proxy): support multiple local core endpoints, one per line (#13923)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-18 11:34:30 -03:00
Abhishek Sharma
b45e0c69e6 feat(security): warn at boot when the inference server is exposed anonymously (#13820)
* feat(security): warn at boot when the inference server is exposed anonymously

`GET /v1/models` follows the dashboard login posture
(`isAuthRequired()` / `requireAuthForModels`) while the inference routes
follow `REQUIRE_API_KEY`. On an instance with an admin password set and
`REQUIRE_API_KEY=false`, `/v1/models` answers 401 while `/v1/responses`
is open to anyone who can reach the port — so the most natural probe an
operator runs reports the opposite of the truth.

#12568 added a boot warning for exactly this combination, but wired it
only into the API bridge and the live dashboard WebSocket. The Next
server that actually answers `/v1/chat/completions` and `/v1/responses`
never reached it, and it is the one that binds every interface by
default (`process.env.HOST || "0.0.0.0"`).

Wire the existing guard into the Next boot hook, and document the split.

Resolving the bound host needed care: two entrypoints bind that server
and they read different variables. `run-next.mjs` honours `HOST`; the
Docker entrypoint delegates to Next's generated `server.js`, which reads
`HOSTNAME`. `run-next.mjs` now publishes what it actually binds as
`OMNIROUTE_BOUND_HOST`, and the guard reads that, then `HOSTNAME`, then
the shared `0.0.0.0` default. `HOST` is deliberately absent from the
chain: the standalone server ignores it, so consulting it there would
warn about an interface the server is not on — and one false warning
teaches an operator to ignore the next one.

Closes #13695

* docs(changelog): add changelog.d entry for #13820
2026-09-18 11:33:39 -03:00
Sean Ford
010250cf08 fix(memory): list provider-node models in the Embedding and Rerank selectors (#13740)
* fix(api): type compatible-provider-node models in /v1/models by the node's apiType

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

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

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

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

* chore(changelog): name the #13734 fragment

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

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

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

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

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

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

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

* chore(changelog): name the #13740 fragment
2026-09-18 11:32:26 -03:00
小妍儿 ✨
6d585625f0 fix(providers): honor Alibaba workspace embedding and rerank endpoints (#13293)
* fix(providers): honor Alibaba workspace embedding and rerank endpoints

* docs(changelog): add Alibaba workspace endpoint fix

* refactor(rerank): keep Alibaba response adapter focused

---------

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

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

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

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

Refs #13137

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

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

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

* docs(changelog): fragment for #13046
2026-09-18 11:30:09 -03:00
Bob.Hou
9956f13b35 fix(db): never TRUNCATE-checkpoint a live WAL (SIGBUS under traffic) (#14005)
* fix(db): never TRUNCATE-checkpoint a live WAL

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

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

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

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

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

Related to #13973.

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

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

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

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

---------

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

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

check-api-typecheck.mjs: OK — 283 errors, baseline ratcheted DOWN from 294
(codex-responses-ws 7→4 TS2339, TS2740 1→0; combos/test and keys/[id] 1→0).
typecheck:core clean; 43/43 unit tests in the touched areas green.
2026-09-18 08:13:43 -03:00
Fouad Salkini
176d632a2d feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos (#13670)
* feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:53:16 -03:00
Aaron Scherer
7d69b02a29 fix(db): skip integrity scans during health polling (#13149)
* fix(db): skip integrity scans during health polling

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Covered by a new test that fails without the change.

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

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 18:47:25 -03:00
Bob.Hou
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
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
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
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
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
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
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
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
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
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
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