mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
fbe195d69e88bb273a131fe4b9a5aa5402f357d1
1620 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 (
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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". |
||
|
|
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. |
||
|
|
fc6b4587ad |
feat(proxy): support multiple local core endpoints, one per line (#13923)
Co-authored-by: Max <maxmad64@gmail.com> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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)
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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). |
||
|
|
46730700f1 |
feat(usage): show separate Fable weekly limits (#13266)
* feat(usage): show separate Fable weekly limits * docs(changelog): add fragment for fable weekly usage |
||
|
|
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
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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 (
|
||
|
|
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 (
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |