Commit Graph

4528 Commits

Author SHA1 Message Date
Tuan Dinh
6fec29ca2d fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider (#13705)
* fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider

* fix(copilot): document COPILOT_INTEGRATION_ID, extract identity fallback, add changelog

Adds the missing COPILOT_INTEGRATION_ID entry to .env.example (fixes
tests/unit/issue-7793-env-doc-sync-repro.test.ts), extracts the GitHub
Copilot 403 identity fallback out of open-sse/executors/base.ts into its
own module (open-sse/executors/copilotIdentityFallback.ts) to bring the
file back under the frozen file-size ratchet, and adds a changelog.d/fixes
fragment for the PR.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: tuandinh0801 <tuandinh0801@users.noreply.github.com>
2026-09-18 11:58:48 -03:00
Alvin T. Veroy
24706705fa fix(streaming): per-provider fetch-start timeout cap override (#11526 follow-up) (#13002)
* fix(streaming): per-provider fetch-start timeout cap override (#11526 follow-up)

Buffered gateways (opencode-go / command-code Console Go tiers) legitimately
buffer a whole reasoning generation before the first upstream byte, so their
streaming requests can exceed the default 110s headers-wait cap. #11526 capped
every streaming request at that ceiling, so these long generations died at
exactly 'Fetch timeout after 110000ms' (504) before any bytes arrived.

Add a per-provider fetchStartTimeoutCapMs registry knob (600s for opencode-go
and command-code) and project it into the executor's LegacyProvider so
resolveFetchStartTimeout caps only genuinely unbounded providers.

* docs(changelog): add fragment for fetch-start cap per-provider override

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: alvinveroy <alvinveroy@users.noreply.github.com>
2026-09-18 11:58:39 -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
Tuan Dinh
4a5f1cd771 fix(providers): Antigravity connection Retest probes Cloud Code envelope (#13010) (#13015)
* fix(providers): Antigravity connection Retest probes Cloud Code envelope (#13010)

* fix(providers): ensure correct argument order for Antigravity discovery and add test

* fix(providers): resolve connection.projectId and surface upstream 400 error message

* docs(changelog): add fragment for #13015

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:58:22 -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
f1eabd8885 fix(sse): stop direct fetch retry reusing pooled flat response-start budget (#13703) (#14047)
resolveDirectHeadersTimeoutMs() (open-sse/utils/directResponseStartTimeout.ts)
now bounds only the pooled dispatcher attempt (attempt 0) with the flat
OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS watchdog. The fresh-socket retry (attempt
1) is by construction a brand-new socket with no zombie-socket risk (#10214's
rationale only applies to the pooled attempt), so when the caller already
attached its own deadline signal it now defers to a generous, configurable
backstop (OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS, default 600s) instead of
reusing the identical short flat window — fixing spurious 504s on healthy
slow-TTFB reasoning models that need well over 60s total for both attempts.

Regression test: tests/unit/proxyfetch-direct-response-start-flat-retry-budget-13703.test.ts
(RED against unmodified code: retry cut at 81ms against an 80ms flat budget
with ~1920ms of caller deadline unused; GREEN after the fix).

New env var documented in .env.example and docs/reference/ENVIRONMENT.md.

tlsProfileForProvider's return type in proxyFetch.ts is pulled into a named
alias so its signature stays on one line under prettier's canonical
formatting -- otherwise prettier's mandatory lint-staged reformat of this
frozen file grows it past the check:file-size baseline on every future touch.

base-red inherited: #14004 (docs env/docs contract, fixed separately in
#14022; chatHelpers file-size drift)
2026-09-18 11:57:51 -03:00
Diego Rodrigues de Sa e Souza
1b82b2f982 fix(combo): stop chars/4 overestimate demoting a verified context override (#13870) (#14046)
filterTargetsByRequestCompatibility ranked combo targets solely on the
chars/4 estimateTokens() heuristic. On a repetitive agent-session body the
estimate overstates real usage several-fold, so a manually-overridden
primary sized correctly for the real request got marked context-incompatible
and was reordered behind an unconfirmed catalog "emergency" member with a
large but unverified limit_context.

Fix: when the reorder branch promotes known-context-compatible targets,
split them by whether their pass came from an operator-set
model_context_override (trusted) or bare catalog metadata (advisory), and
also trust a near-boundary override rejection (required tokens within 5x
the override — covering the ~3.7x overestimate the issue measured) over a
catalog-only pass. An override target keeps or regains priority over an
unconfirmed catalog-only "known compatible" target; two override targets or
two catalog-only targets keep resolving purely on their own fit as before.

Regression test: tests/unit/combo-13870-chars4-overdrops-override-primary.test.ts
(RED before the fix — emergency member promoted to position 0 ahead of the
override primary; GREEN after).

⚠️ base-red inherited: #14004 — docs env/docs contract (fixed separately in
#14022), chatHelpers file-size drift. Not touched by this branch.
2026-09-18 11:57:40 -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
b0955042dc fix(providers): map agentrouter GLM thinking.type adaptive to enabled (#13696) (#14043)
AgentRouter routes GLM models through the generic DefaultExecutor, which has
no GLM-specific handling. When the connection's OpenAI-compatible alternate
format is used, a Claude-style thinking:{type:"adaptive"} field survived
stripUnsupportedParams untouched and reached AgentRouter's upstream GLM
endpoint verbatim, which 400s (thinking.type "adaptive" is not supported by
glm models; must be one of enabled, disabled).

Add a mapThinkingType mechanism to paramSupport.ts's STRIP_RULES (in addition
to the existing drop/clamp mechanisms) and scope a rule to provider
"agentrouter" + model matching /glm-/i that remaps thinking.type from
"adaptive" to "enabled", preserving any other thinking fields (e.g.
budget_tokens) — mirroring the same mapping GlmExecutor already performs for
its own provider.
2026-09-18 11:57:25 -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
460c6075b1 fix(providers): convert agent_message input items for non-Codex-native Responses upstreams (#13698) (#14041) 2026-09-18 11:57:08 -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
e94752fa53 fix(tests): make ReDoS guard assert cost scaling, not wall-clock (#13907) (#14039)
The property test asserted an absolute 250ms ceiling on
sanitizeErrorMessage() for adversarial inputs. That ceiling had no
margin over the pipeline's real fixed cost (3x redact + 2x
normalize passes added by #12506), so it failed on cost under any
machine load, not on backtracking. Replace it with a check that the
sanitizer's cost does not scale with input length beyond a generous
noise allowance, which is what a bounded-backtracking guarantee
actually claims; keep a coarse absolute hang ceiling as a backstop.
2026-09-18 11:56:50 -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
5fce23c957 fix(sse): recognize MCP-gateway-namespaced CCR retrieve tool names (#13781, #13897) (#14028)
callerSupportsCcrRetrieve() matched the omniroute_ccr_retrieve tool by exact
string equality. MCP aggregators/gateways (Docker MCP Toolkit, Claude Code)
rename re-exposed tools with a namespace prefix (e.g.
mcp__docker__omniroute__omniroute_ccr_retrieve, or a dotted/slashed prefix),
so a fully MCP-capable caller reachable only under such a name was treated as
unable to retrieve at all -- skipping both the protocol-instruction injection
and, per the #7746 safety guarantee, the entire CCR compression engine for
that request.

Add matchesCcrRetrieveToolName(), a separator-bounded trailing-segment match
(__, ., /, :) that accepts a namespaced form of the tool name while still
rejecting a near-miss like omniroute_ccr_retrieve_v2 -- never a bare
substring/endsWith check.
2026-09-18 11:56:32 -03:00
Diego Rodrigues de Sa e Souza
25d35179fd fix(security): scope /api/files and /api/batches to caller's tenant (#13882) (#14027)
/api/files, /api/files/[id]/content, /api/batches and /api/batches/[id]
only gated on requireManagementAuth(request), which returns null
unconditionally when settings.requireLogin===false, and never applied
any per-record ownership check. On an instance with login disabled, an
unauthenticated caller could enumerate/download every tenant's files
and batches — the hardened /api/v1/files and /api/v1/batches siblings
already scope via getApiKeyRequestScope()/resolveListScope()/
canAccessOwnedRecord() from the GHSA-2jm2-mpx8-6523 and
GHSA-m3hp-hq9g-fpmv fixes.

Port that exact scoping onto the 4 management routes: an API key sees
only its own files/batches, a dashboard session keeps instance-wide
access, and any other caller is rejected instead of falling through to
an unscoped read.
2026-09-18 11:56:24 -03:00
Diego Rodrigues de Sa e Souza
5b61937f17 fix(sse): declare deepseek 1M default context window (#13922) (#14026) 2026-09-18 11:56:15 -03:00
Diego Rodrigues de Sa e Souza
1c5612c760 fix(api): fail closed on revoked/expired/banned API keys in getApiKeyRequestScope (#13881) (#14024)
getApiKeyRequestScope() resolved apiKeyId purely from getApiKeyMetadata(),
which does a row-existence lookup with no lifecycle filtering. Only
validateApiKey() checks is_active/revoked_at/is_banned/expires_at, and none
of the six /v1/files and /v1/batches route handlers called it directly, so a
revoked, expired or banned key kept a live apiKeyId and canAccessOwnedRecord()
/resolveListScope() kept granting it access to its own records after
revocation (CWE-613).

Fold validateApiKey() into getApiKeyRequestScope() itself: a key that fails
that lifecycle gate is now collapsed into the same { apiKeyId: null,
apiKeyMetadata: null } shape as an unresolved/anonymous caller, so every
consumer of this scope (list reads, per-record ownership checks) fails
closed without each route re-implementing the check.
2026-09-18 11:56:06 -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
Diego Rodrigues de Sa e Souza
9c9ad6bbde fix: land #13161 and #13304 by cherry-pick (locked organization fork) (#14090)
* fix(resilience): treat a Cloudflare managed challenge as a fingerprint rejection, not a ban

A Cloudflare managed/JS challenge served in front of an upstream provider is the
same class of block as a Cloudflare 1010 — the edge refused the CLIENT's
signature — but it is a different product surface and carries none of the 1010
markers isCloudflareFingerprintRejection() looks for.

It therefore fell through the entire 403 ladder in classifyProviderError() to the
terminal default FORBIDDEN, which chatCore persists via writeTerminalStatus() as
testStatus=banned / isActive=false. That state never auto-recovers, so a single
challenge takes the whole provider offline until an operator reconnects in the
dashboard.

Observed on POST chatgpt.com/backend-api/codex/responses/input_tokens for a
healthy Codex OAuth account: the response carried cf-mitigated: challenge,
server: cloudflare and a ~12KB text/html interstitial with
window._cf_chl_opt = {... cType: 'managed', cZone: 'chatgpt.com' ...}. The same
connection refreshed its OAuth token successfully in the same second and served
normal /responses traffic seconds before and after, so the account was never
banned upstream.

Classify the interstitial as FINGERPRINT_REJECTION, reusing the existing
non-terminal precedent from #9929: authTerminalStatus already treats that type as
non-terminal, so the request falls through to the next combo target and the
account state stays untouched.

The markers are matched as full, distinctive Cloudflare-internal strings
(_cf_chl_opt, cdn-cgi/challenge-platform, the challenge-error-text span id
including its escaped-quote nested form) and never as the loose word
"challenge", so provider bodies discussing a challenge in prose are unaffected.

Tests cover the full interstitial, the gateway-nested error.message form, each
marker individually, prose false-positive guards, and regression guards proving
a genuine permission 403 and the ChatGPT Web Sentinel/Turnstile 403 (#8813) both
still classify as FORBIDDEN.

(cherry picked from commit 8204da668a)

* fix(translator): skip replayed web_search_call metadata in Responses-to-Chat

OmniRoute's web-search fallback emits a native web_search_call output item
alongside function_call/function_call_output. Responses clients keep that item
in conversation history and replay it in the next request's input. When the
follow-up turn routes to a Chat Completions target (Claude), the translator hit
its default unsupported-feature branch and returned a deterministic HTTP 400:

  Unsupported Responses API feature: input item type 'web_search_call'
  cannot be represented in Chat Completions

Skip the replayed metadata next to tool_search_call/tool_search_result. The
paired function_call_output still carries the search results, so no context is
lost and the sources are not duplicated into assistant history.

(cherry picked from commit 2b3e63a470)

* chore(changelog): credit the locked-fork landings of #13161 and #13304

Both PRs come from an organization fork (azox-ai) that refuses maintainer pushes
even with maintainerCanModify=true, so they cannot be re-synced in place and are
landed here by cherry-pick with the contributor's authorship preserved
(merge-gates §6). GitHub may not mark the PRs Merged, hence the explicit
credit in the fragments.

---------

Co-authored-by: anhth2 <anhth2@vng.com.vn>
2026-09-18 11:35:50 -03:00
Paco Cartones
57f31105f9 test(dashboard): reactivate logs modal coverage (#14048)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-18 11:35:33 -03:00
Dizzle
de77213b0d fix(build): drop orphaned httpClientAbortGuard.mjs pack-artifact entries (#14029)
The #13636 crash-guard wiring was removed, leaving no producer or
consumer for the dist file. Refs #12732

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-18 11:35:24 -03:00
Dizzle
fec8dc2be9 fix(opencode): record the free-tier refusal instead of counting it as success (#14011)
An OpenCode Zen free-tier 403 ("free tier can only be used from within
OpenCode") reached the end of the executor loop unrecognized: nothing was
persisted about it, and the account that had just been refused was marked
successful, which clears the failure history driving its cooldown backoff. A
refusal was therefore improving the rotation health of the account it hit.

The refusal is now recognized by its own predicate, returned unchanged without
rotating (it is request-scoped, so every sibling account returns the same
verdict), and classified as a non-banning routing error, so the connection
records lastErrorType/lastError/errorCode and stays active.

The account-health reset is also reserved for HTTP successes at both call sites
in the loop, since the same reset ran on any status the loop did not handle in a
dedicated branch.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-18 11:35:15 -03:00
John Costa
875a84e301 fix(docker): copy the app with node ownership instead of a second chown layer (#14010)
The runner-base stage COPY'd the standalone build as root and then ran
`RUN chown -R node:node /app`. On overlayfs a chown rewrites every file it
touches into the new layer, so the published image carried the ~2 GB
standalone tree twice (docker history of diegosouzapw/omniroute:latest:
`COPY /app/.build/next/standalone ./` 2.03 GB followed by
`RUN chown -R node:node /app` 2.04 GB).

Set `--chown=node:node` on the three COPYs that populate /app, drop the
recursive chown, and hand /app and /app/data to node non-recursively next to
the `mkdir -p /app/data` so the data dir stays writable without a volume.

Measured by rebuilding the runner-base COPY/chown sequence against the
published /app tree (root-owned source, same base image, linux/arm64):

  before: 4.38 GB of layers (COPY 2.04 GB + chown -R 2.04 GB), inspect Size 1220846106
  after:  2.34 GB of layers (COPY --chown 2.04 GB),          inspect Size  655117142

The fixed image runs as uid 1000, /app and /app/data are node-owned and
writable, the server boots and healthcheck.mjs exits 0. hadolint output is
unchanged. tests/unit/dockerfile-copy-chown-13990.test.ts guards the
mechanism.

Fixes #13990

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:35:05 -03:00
John Costa
8fbc85ee72 fix(perplexity-web): keep runs of spaces when cleaning non-streaming answers (#14009)
cleanResponse(text, strip = true) replaced every run of two or more
spaces with a single space. The non-streaming path (which tool mode
always uses, since it buffers the full completion before converting
<tool> text into tool_calls) runs cleanResponse with strip on, so any
code the model wrote through perplexity-web lost its indentation: every
nesting level came back as one space, making generated Python
unimportable. Tabs were untouched, which is what pointed at this
normalization step rather than the model.

Fold the space handling into CITATION_RE so the space before a removed
[n] marker goes with it ("text [1] more" still cleans to "text more"),
and drop MULTI_SPACE. Blank-line squashing and trim are unchanged.

Fixes #13968
2026-09-18 11:34:56 -03:00
Tiangao
95cb992c32 fix(providers): honor the base URL override in OpenRouter model discovery (#14001)
* fix(providers): honor the base URL override in OpenRouter model discovery

Model discovery for the built-in `openrouter` provider resolved its catalog
URL from PROVIDER_MODELS_CONFIG, which is pinned to the global
`https://openrouter.ai/api/v1/models`. The per-connection base-URL override
(`providerSpecificData.baseUrl`, set via "Advanced -> override base URL") was
never consulted on the discovery path, while the inference path has honored it
since #6147 (open-sse/executors/base.ts `resolveBaseUrl`).

A connection pointed at a different OpenRouter region therefore kept importing
the global catalog: the per-connection model list, and the auto-sync that
maintains it, advertised model ids the configured endpoint cannot serve. Those
ids only failed later, at inference time, so a region/catalog mismatch surfaced
as what looked like a provider outage.

The two catalogs genuinely differ — the global endpoint advertises ~444 model
ids, the EU in-region endpoint ~58 (a strict subset) — so discovery and
inference disagreed with no signal exposing it.

Discovery now prefers the override for this provider, reusing the existing
`addModelsSuffix()` normalization (drops a trailing chat/responses/messages
path, appends /models, leaves an existing /models untouched). Mirrors the
`openai` override handling added for the same class of bug in #5899. When no
override is set the built-in global catalog is still used.

Tests: tests/unit/openrouter-models-baseurl-override.test.ts covers both the
override and the unchanged default.

* chore(changelog): add fragment for #14001

---------

Co-authored-by: Tiangao (hermes) <montigaud@aikumi.pro>
2026-09-18 11:34:47 -03:00
Aref Alapour
6f55a8c44f fix(providers): keep TinyCMS DOM stub safe for Next.js SSR (#13957)
Never alias window to the Node global without location. The wasm-bindgen
shim now installs a dedicated window with a Location-shaped object and
restores after WASM init/payload generation, so getLocationOrigin cannot
crash every route after TinyCMS is used once.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aref Alapour <aref-alapour@users.noreply.github.com>
2026-09-18 11:34:39 -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
Shahanur Islam Shagor
b6e7bc12ea Fix/combo multimodal capability 13847 (#13863)
* fix(capabilities): align combo multimodal vision hints

* test(capabilities): cover combo multimodal consistency

* chore(changelog): note combo multimodal capability fix
2026-09-18 11:34:21 -03:00
Xmon Dai
bdc79ef883 fix(providers): normalize non-function tools for allowlisted built-in OpenAI-format providers (#13855)
Built-in providers that speak the OpenAI Chat wire format skipped
normalizeOpenAICompatibleTools(), which only ran for custom
openai-compatible-* connections. A client tool whose type is not
"function" (a named Claude server tool, a nameless hosted tool) was
forwarded verbatim, and agentrouter's GLM backend rejected the whole
request with 400 tools[0].type:type is illegal.

Extract the gate into shouldNormalizeFunctionToolsOnly(): custom
openai-compatible-* providers keep normalizing on every target, and a
conservative allowlist of built-in providers (agentrouter first)
normalizes on the OpenAI Chat target only. OpenAI itself stays off the
list, so its custom tools pass through untouched.

Closes #13789
2026-09-18 11:34:12 -03:00
luw2007
74d8690687 fix: reclaim expired half-open probe lease (#13849)
Co-authored-by: luwei.will <luwei.will@bytedance.com>
2026-09-18 11:34:03 -03:00
Paco Cartones
dfde9fc392 fix(sre): redact secrets split across stream chunks (#13837)
Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
2026-09-18 11:33:54 -03:00
legas888Oleg
733f4c1d0a fix(providers): refresh uncloseai free-model roster after upstream rotation (#13825)
* fix(providers): refresh uncloseai free-model roster after upstream rotation

hermes.ai.unturf.com rotated its lineup: /v1/models now serves exactly one
model (Lorbus/Qwen3.6-27B-int4-AutoRound, vllm, max_model_len 65536) while
every previously catalogued id (adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic,
qwen3.6:27b, gemma4:31b) returns 404 on /v1/chat/completions. Requests routed
through the static seed failed although the provider itself is healthy — a
live completion against the new id succeeds.

- registry seed: replace the three dead ids with the live one (+contextLength)
- FREE_MODEL_BUDGETS: 3 rows -> 1 (catalog totals 443 -> 441; counts synced in
  README and free-tier-budget.svg)
- noauth authHint: verified-live-model pointer updated; PROVIDER_REFERENCE.md
  regenerated
- regression test pins the live id and forbids the retired ids in both the
  registry seed and the free catalog

Verified live on 2026-09-15 against https://hermes.ai.unturf.com/v1/models
and /v1/chat/completions.

* docs(providers): sync free-tier entry count after uncloseai merge

The uncloseai roster refresh (3 entries -> 1) dropped the live free-tier
catalog total from 491 to 489 once merged with the current release tip.
README.md and free-tier-budget.svg still quoted 491 after the merge;
update both to the real count so check:docs-counts-sync stays green.

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

---------

Co-authored-by: anon <anon@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 11:33:47 -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
William Echo
2782258846 fix(executors): strip invalid OpenCode stream options (#13819)
Co-authored-by: William Echo <175406538+qinghuanandejiangshi@users.noreply.github.com>
2026-09-18 11:33:31 -03:00
Abhishek Sharma
164043d301 fix(ci): clear the tap.testFiles drift that reds the mutation gate on every PR (#13814)
* fix(ci): clear the tap.testFiles drift that reds the gate on every PR

check-mutation-test-coverage --strict fails on a pristine checkout of
release/v3.8.51 with no PR diff involved, so the mutation-test-coverage
gate is red on every open PR regardless of what it changes.

Six covering unit tests across four mutated modules were absent from
stryker.conf.json tap.testFiles, which means their mutant kills were not
being counted:

  accountFallback.ts          daily-reset-tz-threading, noauth-model-lockout
  sse/services/auth.ts        free-badge-provider-gate, noauth-model-lockout
  combo/comboPredicates.ts    local-token-budget-429-skips-cooldown
  combo/rrState.ts            daily-reset-tz-threading

Four distinct files — two of them cover two modules each. Inserted into
the alphabetical run, matching the file's existing convention; the list
has a second unsorted appended group that is left alone.

After: "No drift — every covering unit test is listed in tap.testFiles",
exit 0. All four files pass (31 tests) so registering them does not
introduce a failing mutation run.

Noticed while reviewing #13743, which targets a fifth file that has
already been registered by 25bc16d87e.

* fix(ci): drop a dangling tap.testFiles entry and guard against new ones

Merging the release line in surfaced that stryker.conf.json still names
tests/unit/plugin-sandbox-permissions.test.ts, which does not exist —
one dangling path out of 428 entries, pre-existing on the base rather
than introduced here.

check-mutation-test-coverage already guards one direction: a test that
covers a mutated module but is missing from tap.testFiles. The other
direction was silent. Stryker resolves the list into its sandbox, so an
entry left behind after its test file is deleted or renamed costs
coverage without failing loudly — the same class of drift this PR is
about, arriving from the opposite side.
2026-09-18 11:33:22 -03:00
Domenico Massafra
6901e72fb8 test(routing): guard Astra vision cutover (#13810)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-18 11:33:14 -03:00
Domenico Massafra
03c1f7545a fix(antigravity): canonicalize tiered flash quota (#13809)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-18 11:33:05 -03:00
小妍儿 ✨
5de6c5108b fix(usage): preserve nested prompt cache reads before cost calculation (#13760)
* fix(usage): preserve nested prompt cache reads

* docs(changelog): add nested cache-read cost fix

---------

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

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

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

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

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

* chore(changelog): name the #13734 fragment

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

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

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

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

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

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

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

* chore(changelog): name the #13740 fragment
2026-09-18 11:32:26 -03:00
Lukas
39cf76c11d fix(gemini): a tool name starting with a digit no longer fails the request (#13738)
Google validates every `functionDeclarations[].name` against one grammar and
rejects the WHOLE GenerateContentRequest when any single one is invalid, so six
`1c_*` tools in a 109-tool MCP catalog made every request 400, including
requests that would never call them (#13715).

`normalizeGeminiToolName` removed invalid characters, collapsed underscores and
stripped leading and trailing ones, none of which touches a leading digit. The
strip is also why a leading underscore was not a workaround: the client's
`_1c_probe` was normalized back to `1c_probe` and rejected for the same reason.

The prefix goes inside the normalizer rather than at the call site, because
that keeps the guarantee on the one value every path reads. `buildHashedGemini
ToolName` builds its name from the normalized string and inherits its first
character, so a fix applied later would hold for short names and fail silently
for the long ones a 109-tool catalog is full of. It also runs before the
collision check, so two names that newly collide are still separated by the
existing hashed path.

The reverse direction needs nothing: the sanitized name now differs from the
client's, so `buildChangedToolNameMap` carries the original and the response
translator restores the client's spelling on the model's functionCall.

Six cells: no declared name starts with a digit, a leading underscore reaches
Google letter-first, the reverse map returns the client's spelling, a
letter-first name is untouched, an over-long digit-first name keeps the
guarantee through the hash path, and two colliding digit-first names stay
distinct. Four mutations, all killed.
2026-09-18 11:32:18 -03:00
Notaloop763
1609767f37 fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases (#13689)
* fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases

* chore: rename changelog fragment to PR number 13689
2026-09-18 11:32:10 -03:00
Lukas
ce56098115 fix(models): keep OpenRouter :batch variants out of chat routing (#13622)
* fix(models): keep OpenRouter :batch variants out of chat routing

ModelSync imported OpenRouter's Batch-API-only variants into the chat
catalogue. A chat completion against one is rejected upstream with

  404 This model is only available through the Batch API.
      Use the /api/beta/batches endpoint instead.

which #13596 measured 91 times in 41 hours, third by volume, plus the
`model not found - locking mode` failover churn behind it.

OpenRouter's /models carries no endpoint metadata that separates a batch
variant from a chat one, so `classifyExplicitEndpoints` cannot decide it
and the rule belongs in `modelEndpointPolicy`, beside the OpenAI
image/video policy and for the same reason: the file exists so discovery,
import and catalog projection agree on one answer.

Matched as the exact `:batch` suffix, not "has a variant suffix" --
`:free`, `:nitro`, `:floor`, `:online`, `:extended` and `:thinking` are
routing hints on the same chat model, and excluding them would silently
shrink the routable catalogue. Applied unconditionally for this provider:
there is no "batch" endpoint name an upstream could declare next to a chat
one, and the already-stored rows carry the synthetic `["chat"]` default
that re-imported them in the first place.

Closes #13596

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

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

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

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

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

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

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

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

* docs(changelog): add fragments for Codex turn-pin fallback and codex-settings apiKey resolution
2026-09-18 11:31:52 -03:00
sprintberlin
2a89a3bba7 fix(translator): flatten root-level anyOf/oneOf/allOf in Claude tool schemas (#13561)
Anthropic's Messages API rejects a tool whose `input_schema` carries a
composition keyword at the root with:

  tools.N.custom.input_schema: input_schema does not support oneOf,
  allOf, or anyOf at the top level

The refusal happens before inference, so a single MCP/agent tool carrying
a root-level union fails every request that ships the catalog, and combo
failover cannot recover from it.

Both conversion paths that build a Claude `input_schema` from a client
payload now flatten such a root union into a plain object schema:
object-compatible branches contribute their `properties` (root wins on a
name collision), the root is pinned to `type: "object"`, and only `allOf`
contributes `required` — `anyOf`/`oneOf` branch requirements are
alternatives and promoting them would refuse calls the original schema
accepts. Nested unions are untouched and clean schemas pass through
unchanged.

Closes #13552
2026-09-18 11:31:43 -03:00
Xore
1c19e2c034 fix(compression): place output-style instruction in top-level system, not messages[0] (#13383)
* fix(compression): place output-style instruction in top-level system, not messages[0]

Anthropic-shaped bodies reject a synthetic system-role entry unshifted at
messages[0] (the claude passthrough forwards messages unchanged, and the
upstream API requires system content in the top-level system parameter).
Output styles and the caveman output mode now land their instruction in
the top-level system field when it exists (string or content-block array)
and only fall back to a trailing system message for OpenAI-shaped bodies,
which the claude system-role extraction hoists. Custom endpoint system
prompts skip the unshift whenever a top-level system field is present.

All compression options stay enabled; placement-only fix.

Fixes #12584

* test(compression): cover system-instruction placement branches 4 and 6

Adds direct unit cases for the placement ladder branches that had no
coverage, asserting where the instruction lands:

- branch 4: merge into a system message at index >= 1, leaving
  messages[0] untouched (plus the skip-over-a-block-content system
  message variant).
- branch 6: trailing append when the body has neither a `system` field
  nor a string-content system message.
- block-array `system`: appends one block and is idempotent on a second
  pass (first coverage of the array path of the marker check).

Also normalizes a malformed non-string/non-array top-level `system`
(e.g. `null`) to "" before the format branch in injectSystemPrompt and
injectCustomSystemPrompt. Previously such a body entered the `system`
branch, matched neither format, and silently dropped the prompt without
falling back to the messages path. Both new guards fail without this
change.

* chore(compression): add changelog fragment for top-level system placement
2026-09-18 11:31:27 -03:00
Felipe Britto
138ccf2d04 fix(build): copy ioredis and bcryptjs into the standalone bundle (#13352)
Both packages are only reachable through code paths the standalone
tracer never follows, so they get silently dropped from the built
node_modules/:

- ioredis is a deliberately lazy dependency (#6559 in
  rateLimiter.ts) — reached only via a runtime `await
  import("ioredis")` in rateLimiter.ts,
  warmupScheduler/circuitBreakerFactory.ts and
  quota/redisQuotaStore.ts, never through a static top-level import.
  Any self-hosted deployment that sets REDIS_URL crashes on first use
  with "Cannot find module 'ioredis'".

- bcryptjs is statically imported by
  src/lib/auth/managementPassword.ts, so the main server bundle is
  fine (Next inlines the small pure-JS package into the compiled
  chunk). bin/cli/settings-store.mjs (the `omniroute reset-password`
  CLI) is a separate, unbundled entrypoint that needs the real
  package physically present in node_modules/ — nothing else
  requires it as a loose runtime dependency, so it was never copied.
  `node bin/reset-password.mjs --password-stdin` failed with
  "Cannot find package 'bcryptjs'" (ERR_MODULE_NOT_FOUND) on an
  otherwise healthy production deployment.

Both reproduced on a real self-hosted Docker deployment (v3.8.49/51).
Adds the two entries to EXTRA_MODULE_ENTRIES (the single source of
truth cited in the Dockerfile) and extends the sync/async parity
test with fixtures + assertions for both.
2026-09-18 11:31:10 -03:00
Abhishek Sharma
53a147c7ca fix(backend): stop redaction truncating the message after a path (#13295)
* fix(backend): stop redaction truncating the message after a path (#13144)

`findUnquotedPathEnd` may swallow the rest of a line when it cannot tell
where a path ends, so a Windows path with spaces cannot leak a
`Files\secret` suffix. Two things made that fire far wider than the
function documents at `:606`.

**1. The licence was granted on separator evidence alone.** Every API route
carries slashes, so an ordinary `/v1/x/y` in prose qualified as unequivocal
and truncated everything after it. The image-model 400 lost the one
sentence it exists to deliver:

    built     ...cannot be used on /v1/chat/completions. Use POST
              /v1/images/generations instead.
    delivered ...cannot be used on <path>

Now only a Windows path, a file URI, or a known POSIX filesystem root may
swallow the line. @diegosouzapw's `/zz` vs `/etc` probe on the issue is why
this is the condition and not the first-segment check I originally
proposed: both truncated identically, so the root was never the driver.

**2. The ambiguity branch ran before `resolvedExtensionEnd`.** A path whose
end is pinned exactly by a known extension was still treated as ambiguous
the moment any prose followed it, so the endpoint was discarded and the
line swallowed. A determinable extension leaves nothing to fail closed
about -- the whole path is still replaced, suffix included, and the tail
survives:

    before  Provider failed in <path>
    after   Provider failed in <path> with api_key='[REDACTED]'

Both halves are independently load-bearing: reverting (1) fails the two
route tests, reverting (2) fails the extension test.

Fail-closed is narrowed, not weakened. `/etc/shadow copy failed` has
nothing to anchor an endpoint on and still collapses to `reading <path>`.
Worth recording that the guard test for that cannot be killed by mutating
either mechanism alone -- the two are mutually redundant, so it takes
disabling both, which is also why this change cannot expose a suffix these
shapes did not already hide.

Test results against base:

  chat-rejects-image-only-model          red -> GREEN
  dashboard-request-failed-redaction     its delivered-log assertion now
                                         passes; the test still fails on a
                                         second, unrelated assertion (the
                                         *internal* log is redacted where it
                                         should stay raw) that base never
                                         reached
  tunnel-routes-error-sanitization       unchanged, independent (a tripwire
                                         asserting the shared sanitizer does
                                         NOT cover a shape it now does)

836/840 pass across the sanitization, redaction and error suites; the
remaining failures are the two above plus mcp-public-error-boundaries,
which passes in isolation on base and on this branch and only flakes under
--test-concurrency=8.

* docs(changelog): fragment for the redaction truncation fix (#13144)

changelog.d/README says a PR adds exactly one fragment rather than editing
CHANGELOG.md, so the aggregation order stays deterministic and siblings
cannot conflict. This one was missing.

* test(redaction): narrow the headline case to the truncation it names

Rebased onto a base that has moved 40 commits; resolveEndpoint gained an
ignoreAmbiguity parameter and route-context callers in that window. The
merge keeps both: base's parameter, plus this PR's two changes (check the
resolved extension BEFORE the ambiguity branch, and drop
hasFilesystemEvidence from the swallow licence).

The headline assertion was that the whole message survives byte for byte.
On the current base the tail survives but the route itself still becomes
<path> in that particular message, because the quoted model slug earlier
in the line carries separators. That is a narrower, separate question from
the truncation this PR fixes, so the test now asserts the remediation
sentence survives and records the <path> substitution explicitly rather
than silently dropping the case.

* test(stryker): register the redaction-truncation test for mutation runs

errorPathRedaction.ts is mutation-tested, so a new covering test has to be
in tap.testFiles or the Stryker sandbox never runs it and its mutants
report as survived. Inserted in the alphabetical run beside the sibling
error-sensitive-redaction.test.ts, following #13036's precedent.

The list has a second, unsorted appended group; left that alone rather
than re-sorting a file this PR only needed one line in.

Requested in review by @diegosouzapw.
2026-09-18 11:31:02 -03:00