A client that closes its connection right after reading a fully-completed
SSE stream can race OmniRoute's own completion bookkeeping: the bytes
already reached the client, but the transform stream's own completion
callback (onStreamComplete, which flips streamCompletionRecorded) hasn't
finished bubbling up when the disconnect handler fires, so the request gets
persisted as a false 499 with zero token usage even though it delivered its
full response.
Confirmed live on real traffic before this fix: a request whose server log
showed "disconnect: request_signal_aborted" at 18236ms was persisted with
status 200 and full token usage (82814/1292) once the grace period let the
real completion win the race, matching what the client actually received.
createClientDisconnectGraceHandler (new leaf in
streamFailureFinalization.ts) polls isStreamCompletionRecorded() for up to
STREAM_DISCONNECT_GRACE_PERIOD_MS (default 10s, env-configurable, 0
disables) before finalizing as a failure. If a real completion lands within
the window, handleStreamFailure's own guard is a no-op and the genuine 200
stands.
Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (fake-timer
driven: already-recorded completion short-circuits, disabled-grace-period
finalizes immediately, a completion landing mid-window skips finalize
entirely, and no completion ever landing finalizes once the deadline
passes).
(cherry picked from commit 5d0fe28c42)
* fix(sse): persist per-tool-call JSON escape state across SSE delta chunks
escapeJsonStringValues() reset its inString/pendingEscape state on every
call instead of carrying it forward per tool-call index, so a raw newline
byte (or an already-escaped \n) split across two delta chunks got corrupted
in transit — the model's own output was correctly escaped, OmniRoute broke
it. Root-caused via a dispatched investigation into real OpenClaw traffic
that looked like model-generation quality but wasn't.
Fix: escapeJsonStringValues now takes and mutates a persistent per-call
state object (JsonStringEscapeState), keyed per tool-call index in the
translator's init state and cleared when a tool call is superseded.
* chore(quality): rebaseline openai-responses.ts for the escape-state fix
Own growth from the extracted per-tool-call JSON escape-state fix
(previous commit): open-sse/translator/response/openai-responses.ts
1204->1249 (+45).
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
* fix(compression): add Lite tool truncation toggle
* fix(antigravity): add missing antigravityProjectPersistence.ts module
The quota-strategy engine (quotaStrategies.ts) imports from
antigravityProjectPersistence.ts, but only antigravityProjectPersist.ts
existed in the tree. Add the missing module with the expected
preferAntigravityConnectionsWithStoredProject() helper and re-export
the existing persistDiscoveredAntigravityProjectId().
Co-authored-by: diegosouzapw <diegosouza.pw@outlook.com>
* fix(file-size): rebaseline strategySelector.ts for Lite truncation toggle
The PR adds one line to threading options?.config?.lite into
applyLiteCompression. Update the frozen size from 1060 to 1061.
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Refs #9629
---------
Co-authored-by: Xiangzhe <xiangzhedev@gmail.com>
Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Renumber the CCR block-store migration from 134 to 139, reconcile databases that already applied the legacy slot, and add regression coverage for both upgrade paths.
Co-authored-by: fenix007 <fenix007@users.noreply.github.com>
* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login
POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but that service looks up the provider by
slug in TOKEN_EXTRACTION_CONFIGS. The lookup always missed and returned
"No extraction config" without launching a browser — so the VibeProxy
"Sign in" button for Adobe Firefly (and every other web-cookie provider)
never opened a browser.
Adobe Firefly additionally had no extraction config because its IMS JWT
is never in cookies/localStorage — it only rides on the Authorization:
Bearer header of firefly-3p.ff.adobe.io XHRs.
- Resolve the provider slug from the connection row and pass the slug
(not the DB id) to inAppLoginService.startLogin.
- Add open-sse/services/adobeFireflyBrowserLogin.ts: a Playwright
service that launches a visible browser at firefly.adobe.com and
intercepts firefly-3p requests to capture the IMS JWT + sherlockToken
cookie. Wire it into the /login route for the adobe-firefly slug.
- Fix latent bug: updateProviderConnection reads camelCase keys
(apiKey, providerSpecificData), so the previous snake_case call never
persisted extracted credentials.
* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login
POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but TOKEN_EXTRACTION_CONFIGS is keyed by
provider slug — so browser login never launched for web-cookie providers.
Adobe Firefly also cannot use cookie extraction: the IMS JWT only appears
on Authorization headers to firefly-3p.ff.adobe.io. Add a dedicated
Playwright interceptor and persist credentials with camelCase keys that
updateProviderConnection actually reads.
* fix(adobe-firefly): use system Chrome/Edge CDP for browser sign-in
Playwright is not available inside the pkg-packaged VibeProxyServices.exe,
so import('playwright') always failed with 'Playwright not installed' and
never opened a window. Launch Chrome/Edge with --remote-debugging-port and
capture the firefly-3p Authorization Bearer via pure CDP WebSocket instead.
* fix(adobe-firefly): live x-arp-session-id / Arkose wire (stop 408 under load)
Browser generate-async requires x-arp-session-id as base64({sid,ark,ftr}) with a
real Arkose blob (sherlockToken). JWT alone frequently returns colligo HTTP 408
system under load while credits still work.
- Match live ftr magic __UDF43-m4_31ck + Arkose pk in synthetic ARP fallback
- Ranked extract of sherlockToken / x-arp from Cookie, HAR, fetch() paste, and
space-joined JWT+ARP (PasswordBox newline collapse)
- Reuse one ARP for storage upload + generate-async
- Clearer 408 errors when browser ARP is missing vs stale
- Unit suite 42/42
* fix(adobe-firefly): durable session ARP rebuild and aux_sid false-positive
Rebuild x-arp-session-id from forterToken/arkose/ff_session_guid instead of
ranking long Cookie pairs (e.g. aux_sid=…) as opaque ARP, which caused colligo
HTTP 408. Cache IMS JWT + cookie sessions, rotate ARP on 408 retries, and keep
Playwright warm-up opt-in only (headless Forter is rejected).
Also expand synthetic ARP shape with bfp/fpjs to match live successful captures.
* fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in
Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never
sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash.
Add optional managed Chrome warm (off-screen headed by default; Forter rejects
headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie
after a fresh SSO. Visible sign-in resets off-screen window placement and clears
prior Adobe session when adding another account.
* fix(adobe-firefly): renew sessions through durable CDP
* fix(adobe-firefly): isolate browser sessions per account
* fix(adobe-firefly): make account login fresh and deterministic
* chore(adobe-firefly): remove obsolete browser fallback
* docs(adobe-firefly): document renewal controls
* fix(adobe-firefly): harden CDP warm, risk session, and browser sign-in
Stop colligo 408 thrash from stale Forter and frozen Google login during
Sign in with browser:
- CDP warm: clear Firefly origin storage + risk cookies (keep SSO); require
forter age under 10 minutes on loop and timeout paths; dual CDP queues;
await Runtime.runIfWaitingForDebugger; profile-lock launch retries
- Session: connectionId fingerprint; write-back JWT+Cookie; warm-fail
cooldown; fail closed risk_session_stale when forter is known-stale
- Client: submit gate around generate-async; max 2 attempts when forter
known-stale; poll 401 one refresh; pass sessionBrowserKey through handlers
- Login route: pure system Chrome/Edge CDP only; camelCase credential persist
- Unit: browser-login + firefly suites green (60)
---------
Co-authored-by: artickc <artur1992123@mail.ru>
* feat(api): add GET /api/resilience/connections for per-account state
The three temporary-failure mechanisms each have their own scope -- the
provider circuit breaker covers a whole provider, connection cooldown covers
one account, model lockout covers a provider/connection/model triple -- and
until now nothing showed them side by side. Diagnosing "why is this key being
skipped" meant reading three separate surfaces and correlating by hand, which
is exactly what the docs' own debugging guidance asks an operator to do.
The route returns all three keyed by connection, plus the breaker's transition
history so a flapping provider is visible as a sequence rather than a single
current state. getStatus() already assembled everything except that history;
it now returns a copy of it and carries an explicit CircuitBreakerStatus type
instead of an inferred one.
Reading raw connection rows for this meant widening getRawProviderConnections'
column projection, so the existing allowlist is exported and the route selects
through it. A test asserts every column the route names is in that allowlist,
which turns a future typo into a failure here rather than a silent empty field.
Each of the three data sources is wrapped independently: one of them throwing
degrades that section and sets meta.degraded rather than failing the whole
response, since a partial view still answers most of the questions the page
exists for.
Loopback-gated. It spawns nothing, unlike every other entry on that list, but
it exposes per-account operational state and the comment says so to keep it
from being read as precedent for gating read-only routes generally.
Tests are real isolated-DB integration tests rather than mocks -- ESM mocking
is unavailable here (no mock.module, non-configurable exports) and the
codebase already has the isolated-DB pattern, which exercises more than a mock
would anyway.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* feat(dashboard): add the per-account resilience connections page
Renders what the API added: every connection with its cooldown, its provider
breaker, and its model lockouts in one table, with a detail view per connection
and the breaker's transitions drawn as a timeline. The timeline is the part that
is hard to get from the existing surfaces -- a breaker sitting at CLOSED right
now looks healthy, and only the sequence shows it has opened four times in the
last hour.
Polls rather than streams. The state it displays changes on the order of
seconds to minutes and the page is loopback-gated, so an SSE channel would buy
nothing over an interval.
ModelCooldownsCard had its own formatRemaining. The new table needs the same
countdown format and two copies would drift, so it moves to
shared/utils/formatRemaining.ts and both import it -- behaviour unchanged, the
extracted version differs from the deleted one only in local variable names.
DataTable's column and row interfaces are exported for the same reason: the new
table types against them rather than restating their shape.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* fix(i18n): translate new resilience-connections screen strings
PR #9510 added the "Connection Resilience" dashboard screen but the
sync-added i18n keys (sidebar.resilienceConnections/Subtitle and the
full resilienceConnections namespace) were left as __MISSING__: in
every non-English locale, dropping i18nUiCoverage.pct below the 99
ratchet baseline.
Translate all ~78 new leaf strings into all 41 non-English locales.
Pre-existing unrelated __MISSING__ debt (hermesRole*, apiProtocol*,
grokAutoTopUp*, featureFlagExposeFunctionalGatewayMirrorsDescription)
is left untouched — out of scope for this fix.
Co-authored-by: HouMinXi <HouMinXi@users.noreply.github.com>
---------
Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: HouMinXi <HouMinXi@users.noreply.github.com>
* fix(combo): restore routing module load
* fix(db): resolve ccr migration version collision
Renumber the CCR block-store migration from 134 to 139, reconcile databases that already applied the legacy slot, and add regression coverage for both upgrade paths.
Co-Authored-By: GPT-5 <noreply@openai.com>
* fix(changelog): format the aggregator balance fragment as a bullet
The fragment landed with YAML frontmatter rather than the bullet the
aggregator reads, so check:changelog-integrity exits 1 on every branch and
takes the merge-integrity job down with it regardless of what the branch
changed.
Only the format changes. The entry text is the author's, unedited, and now
carries the link to the pull request that shipped it.
* fix(test): update expected auth/vision/provider schema for base-drifted expectations
* fix(test): narrow this branch to the drifted test expectations
Three other PRs already cover what this one was carrying. #9618 renumbers the
colliding ccr_blocks migration, #9632 repairs the malformed aggregator changelog
fragment, and #9676 restores the combo module load by implementing the selection
helper the import was reaching for, rather than deleting the caller the way this
branch did. Keeping any of it here would put two files back on the same migration
slot and overwrite a better fix with a worse one.
What survives is the part none of them touch. Once the combo barrel loads again,
three assertions in the context-window filter suite start failing: they demand
that catalog-too-small targets be dropped, while the file's own header and its
four neighbouring tests say those targets stay available as runtime fallback.
The unresolved import was masking them. A new case pins the output-token limit
as a genuine hard requirement so the relaxation cannot drift further.
The provider count assertion kept one literal at the old value after the rest of
the file moved to 198, so the partition check failed on a sum that was correct.
* chore(quality): re-time migrationRunner for the 139 guard on the new tip
---------
Co-authored-by: alexey.nazarov@softmg.ru <alexey.nazarov@softmg.ru>
Co-authored-by: GPT-5 <noreply@openai.com>
Co-authored-by: Minxi Hou <houminxi@gmail.com>
* fix(providers): add per-provider opt-out for anonymous no-auth fallback
API-key providers with anonymousFallback: true (opencode-go, opencode-zen,
pollinations, kilocode) receive a synthetic "noauth" connection whenever all
real connections are terminal (credits_exhausted/banned/expired) or
unavailable. The opencode upstream now rejects anonymous requests with
401 Missing API key, so the fallback adds a guaranteed-failing round trip
and health/reconnect noise before the combo moves on.
Add a noAuthFallbackDisabledProviders settings array (zod-validated,
persisted via /api/settings, following the blockedProviders pattern).
When a provider is listed, maybeSyntheticNoAuthFallback returns null for
anonymousFallback-only providers, so exhausted providers are skipped
immediately as allExpired/allRateLimited while real keyed connections keep
working and recover automatically once quota state clears. True no-auth
providers are unaffected; blockedProviders remains their disable mechanism.
Default (absent/empty list) preserves current behavior.
Provider detail pages for anonymousFallback providers gain an
"Anonymous fallback" toggle (default ON) backed by the new setting.
Refs #9674
* fix(auth): reduce file size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Hermes Agent <hermes@hermes-chloe.hyades.io>
* fix(providers): refuse to store the dashboard password as a connection API key
A browser autofilled the management password into a connection's API-key field.
The resulting credential authenticates against nothing, so every request routed
through that connection came back 401, and because the field looks like any
other password input the same autofill fired again while the connection was
being repaired by hand.
The refusal belongs on the write path rather than in the form. Twenty routes
create or update connections and all of them funnel through
createProviderConnection and updateProviderConnection, so one check there covers
every entry point including a future one. The two other places that write
api_key are left alone on purpose: one re-encrypts rows that already exist and
the other is the one-time db.json import, and neither takes a value an operator
just typed.
Update checks the incoming value, never the merged one. A connection that
already holds the password has to stay editable or the operator cannot repair
the exact state this prevents, and re-checking the merged value would spend a
bcrypt round on every unrelated field edit.
Only a real match blocks the write. An unreadable settings row or a throwing
bcrypt call logs and allows, because a guard against one specific mistake must
not turn into a way to lock out every connection write.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* fix(providers): compare the untrimmed credential, and cover the guard's branches
The guard trimmed the incoming value before comparing it, which catches a paste
carrying whitespace the password does not have. It missed the mirror case:
neither the login route nor the set-password route trims, so a dashboard
password may itself begin or end with a space, and an autofill reproducing it
exactly was trimmed into a value that no longer matched the stored hash. The
write then went through, which is the state this guard exists to prevent. Both
forms are compared now, the second only when the first fails on a string that
differs, so an ordinary key still costs a single bcrypt round.
Two branches carried no coverage and both are load-bearing. The catch that logs
and allows is the only path that lets a write through; a stored hash bcrypt
cannot parse reaches it without needing a mock, since the shape check accepts an
impossible cost factor that the comparison then rejects. The early return is
what keeps a token renewal -- a write carrying tokens but no apiKey -- from
paying for a settings read and a bcrypt round every time it fires, and the same
unparseable hash makes that path observable, so an absent warning is proof the
return happened.
The narrower scope is deliberate and now says so in the code: the OAuth tokens
arrive from a provider's token endpoint rather than from a form, so extending
the comparison to them would charge every renewal for a field no autofill can
reach.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
---------
Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Minxi Hou <houminxi@gmail.com>
* ci(test): route orphaned Vitest tests through blocking CI
* docs: fix advisory status in AGENTS.md and refresh baseline note
* fix(changelog): fix fragment format for #9415
* fix(changelog): preserve upstream fragment format
---------
Co-authored-by: MohitRawat017 <rawatmohit17906@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
* feat(db): add a job registry for scheduled background work
Background jobs each ship their own timer today, so there is no list of what
is scheduled, no history of what ran, and no way to pause one without an
environment variable and a restart. The registry gives them one home: a jobs
table holding the schedule, a job_runs table holding the outcomes, and a
loopback-only API to inspect and control both.
Cron jobs read their expression through an optional cronGetter rather than the
stored column, so an operator changing OMNIROUTE_WARMUP_CRON does not need the
row rewritten. register() is an idempotent upsert that refreshes the schedule
but never overwrites `enabled` or `created_at`, which is what lets a job be
re-registered on every boot without discarding the operator's toggle.
Run history is pruned per job rather than globally, and safeRun records a
failure for a handler that throws as well as one that returns success:false,
so a crashing job leaves a trail instead of a gap.
The API is under /api/jobs and gated to loopback in the route guard. It can
trigger a run and flip a job off, which is runtime administration and does not
belong on a remotely reachable surface.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* feat(jobs): move the budget reset and token health check onto the registry
Both jobs owned their own timer and started themselves as an import side effect,
so nothing could report whether they were running, when they last ran, or why a
run failed. They now register with the job registry and are started from it, which
also means their schedule and run history are visible through /api/jobs.
startAll() runs each interval job's first tick synchronously, so both entry points
start the registry only after initializeCloudSync() has been awaited. The old
wiring reached that ordering two different ways: the budget reset was started
after the init call, and the health check's first sweep sat behind a 10s timer.
Replacing both with one startAll() would otherwise have moved the two handlers
in front of the initialisation they run against.
Both entry points also register the same pair of jobs. Registering one and not
the other is how a background job goes missing without anything failing.
sweep() now returns how many connections it swept, so the health check can record
a real records_affected the way the budget reset does. The migration documents
that column as a per-job count, and hardcoding zero would have left one of the two
jobs reporting a number the schema promises but the code never produces. A skipped
or empty sweep reports zero. Every existing caller ignores the return value.
The token health check keeps its own disable semantics: the handler still calls
isHealthCheckDisabled() before sweeping, so OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK,
the production-build phase and the automated-test guard behave as before. Its
registry adapter lives in src/lib/jobs/ next to the budget reset rather than in
tokenHealthCheck.ts, which is already above its frozen size ceiling on the base
branch and should not grow further. The adapter lets a failing sweep throw rather
than reporting it itself, matching the budget reset: safeRun records a thrown
error as a failure run with its message.
The warmup job is seeded disabled. Its handler arrives with the warmup scheduler,
and startAll() filters on enabled before it looks for a handler, so seeding it
enabled here would warn about the missing handler on every boot.
* fix: allowlist cron-parser dep and document OMNIROUTE_RUNNOW_TIMEOUT_MS env var
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
---------
Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
* fix(web-tools): anchor tool contract at prompt tail + user-turn reminder
The <tool> contract from prepareToolMessages was prepended as the first
system message. Web executors fold all system messages into one block, so
with agentic clients whose system prompts exceed ~28K chars the contract
sat at the head of a huge block and web models ignored it, refusing tool
calls with "tool X is not in my tool set" (chatgpt-web, 0/3 at 30K chars).
Two changes, both required in testing:
- Dual placement: the full contract now rides as a trailing system
message (folds to the tail of the system block) and a one-line
reminder naming the tools is appended to the latest user message.
- Rewording: the contract now frames injected tools as client tools
invoked via a plain-text protocol, distinct from the model's native
tool registry (web.run, python.exec, ...), and instructs the model to
never claim they are unavailable. Without this the model resolved
tool names against its native registry and refused even when it had
seen the contract.
Measured on cgpt-web gpt-5.5-thinking/gpt-5.6-thinking/o3: prepend 0/3
tool calls at 30K chars; dual placement 16/17 across 30K-250K system
prompts, 30-tool sets, multi-turn tool history, streaming, and 3-way
concurrency, with no spurious calls on no-tool prompts. Known limit:
~40K-char single user messages still flake (2/3) due to the upstream
model's own injection heuristics.
All prepareToolMessages consumers parse system messages
position-independently and select the current user turn by role scan,
so the trailing system message is shape-safe for every web executor.
* test(web-tools): cover contract placement edge cases
---------
Co-authored-by: Ryan Brosas <ryanjoserbrosas@gmail.com>
* build(docker): make the bundler build-arg actually take effect
A bare ENV shadows a same-named ARG for the rest of the stage, so
--build-arg OMNIROUTE_USE_TURBOPACK=0 was silently ignored and the
webpack escape hatch the surrounding comment advertises only ever
worked through -e at runtime, never at build time.
That mattered because Turbopack compiles in native Rust memory living
outside the V8 heap, so OMNIROUTE_BUILD_MEMORY_MB cannot bound it. A
build host with a memory ceiling gets SIGKILLed by the cgroup OOM
killer with no error text at all, which reads like a hung build rather
than an out-of-memory one.
* docs(docker): correct the builder stage facts and document its cost
The stage table described a builder that no longer exists: it named
node:24.15.0-trixie-slim where every stage now derives from
node:26-trixie-slim, and said the stage runs `npm run build -- --webpack`
where it runs plain `npm run build`, which is Turbopack by default.
That second one is worse than stale. A reader who needs the webpack
fallback would conclude the Docker build already uses it and never look
for the switch.
Adds a Build-time resources section covering the two build args, why the
V8 heap arg cannot bound Turbopack, and measured ceilings for both
bundlers. The runtime paragraphs that followed get their own heading so
they no longer read as part of the build-time story.
* docs(docker): correct the runtime heap defaults
Same drift as the builder stage, in the paragraphs just below it. The
image exports OMNIROUTE_MEMORY_MB=1024 and derives NODE_OPTIONS from it,
but the guide reported 512 in three places, including the environment
variable table.
The "if unset, the launcher uses 512" line was misleading in both
readings: the image always sets the variable so that branch cannot fire
under Docker, and outside Docker the launcher calibrates from host RAM
rather than using a flat 512.
* docs(changelog): add fragment for #9695
---------
Co-authored-by: Minxi Hou <houminxi@gmail.com>
* fix(db): renumber ccr_blocks migration 134 -> 139
134 was taken by 134_proxy_logs_egress_ip, so two migrations shared the
same numeric prefix and check-migration-numbering failed. Move ccr_blocks
to the next free slot and add the retroactive isSchemaAlreadyApplied guard
so a DB that already applied it under 134 skips the re-run.
* fix(combo): restore missing preferAntigravityConnectionsWithStoredProject
quotaStrategies imported the reset-aware pool filter from
../antigravityProjectPersistence.ts, a module that does not exist — the
helper belongs in antigravityProjectPersist.ts and was never added there,
breaking typecheck. Add the helper alongside the persist path, point the
import at the real module, and cover the filter with unit tests.
* chore: add Makefile wrapping the canonical npm scripts
* fix(compression): remove duplicate Antigravity project helper
The release branch already includes the generic project-aware connection
selection helper. Keep that implementation and remove the duplicate introduced
while cherry-picking #9707.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Matias Baglieri <168452313+matiasbaglieri@users.noreply.github.com>
* fix(build): colocateLlmlinguaOptionals skip-check treated a Next-traced stub as fully copied
Debugging the omniroute-beta Docker rebuild: `npm run build` (and the
Dockerfile's own post-build verification) failed with
`Cannot find module '.../node_modules/@atjsh/llmlingua-2/dist/index.js'`.
Root cause, reproduced directly (both against a live Docker builder image
and in a unit test): Next.js's own standalone trace creates a stub
directory for `@atjsh/llmlingua-2` containing only `package.json` — it
references the package (a dynamically-imported optional dependency) but
can't fully bundle it. colocateLlmlinguaOptionals's skip checks (both the
closure-level early return and the per-package loop) only tested
`existsSync(dest)`, so that stub was indistinguishable from "already fully
co-located" — the function skipped copying the real `dist/` output
entirely, silently shipping a package with a manifest but no code.
Fix: check for the package's declared `main` entry file when it has one
(the real-world case for every actual SLM optional). Packages with no
`main` field fall back to comparing the destination's top-level entries
against the source's — correct both for genuinely multi-file packages and
for a metadata-only source (package.json is then its complete, faithfully-
copied contents), which the existing idempotency test exercises.
Covered by tests/unit/colocate-optionals.test.ts's new stub-reproduction
case (fails against the pre-fix code, passes after — confirmed directly)
plus the 6 pre-existing cases, all still green.
(cherry picked from commit 359aba59c7)
* fix(build): register onnxruntime-node's native bin/ as a standalone asset (#9687)
Docker/standalone builds of the LLMLingua SLM compression tier failed at
runtime with "Error: libonnxruntime.so.1: cannot open shared object file:
No such file or directory" (open-sse/services/compression/engines/llmlingua's
worker, via @huggingface/transformers -> onnxruntime-node).
onnxruntime-node's dist/binding.js is a normal JS file Next.js's standalone
trace bundles correctly, but binding.js dlopen()s a platform-specific native
library shipped under bin/napi-v3/<platform>/<arch>/libonnxruntime.so.1 — a
dynamic native load static file tracing can't see (same blind-spot class as
the separate colocateLlmlinguaOptionals stub bug, just for a .so instead of
a JS import, via NATIVE_ASSET_ENTRIES instead). That directory was simply
never registered, unlike better-sqlite3's native binary, which already goes
through the exact same mechanism correctly.
Fix: add an entry for onnxruntime-node/bin, mirroring the existing
better-sqlite3 entry. Confirmed against a real Docker build of the
Dockerfile's own post-build verification step: this was the very next
failure once the separate llmlingua-2 stub bug was fixed and the build
progressed far enough to reach it.
Covered by tests/unit/assemble-standalone-onnxruntime-native-asset.test.ts
(fails against the pre-fix code on both assertions, passes after).
(cherry picked from commit 8c98a59f26)
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
Wire Openference as a first-party OAuth gateway (PKCE, rotating refresh)
and an API-key catalog entry on api.openference.com, with live model
discovery, connection testing, free-tier badges, and regression tests.
Co-authored-by: Anh Tran <anhlead@outlook.com>
A phone that previously loaded a production build on this origin (or
an old dev build from before the registration was gated) kept an
active service worker across dev restarts. It intercepted every
navigation/asset fetch, occasionally serving a JS chunk that didn't
match the running dev server, which tripped Next's dev-client
chunk-mismatch auto-reload — visible as an unexplained, unstoppable
refresh loop on that device only (confirmed via a clean private tab
on the same phone/URL not looping).
PwaRegister now actively unregisters any existing service worker
registrations and clears their caches outside production, instead of
just skipping a new registration.
(cherry picked from commit 66a2515cbc)
Co-authored-by: Markus Hartung <mail@hartmark.se>
* fix(compression): persist RTK renderer configuration
* docs(changelog): add fragment for #9730
Adds the changelog.d/fixes/9730-persist-rtk-renderers.md fragment
required by check:changelog-integrity for the RTK enableRenderers
persistence fix in PR #9730.
---------
Co-authored-by: Isaac <isaaclyons98@gmail.com>
Every duckduckgo-web chat request failed with HTTP 418 ERR_CHALLENGE while
duck.ai worked normally in a browser from the same IP. Ground truth was
established by driving a real headful Chromium at duck.ai from that IP (it
returned 200), so the environment was never the problem — the anti-abuse
challenge solver was. Six independent defects were found; the first alone
disabled the solver completely.
1. Module syntax inside the vm sandbox source.
CHALLENGE_STUBS is executed with vm.runInContext, which compiles in SCRIPT
mode. A refactor mass-added `export` to the five `function` declarations
inside that template literal (they read as ordinary top-level TS functions),
so every solve threw SyntaxError. The executor swallows solve failures and
posts the raw unsolved challenge, which upstream answers with 418.
2. Double-escaped regex in a String.raw template.
`\\s` in __parseCssDisplay reached the sandbox as a literal backslash, so the
display regex never matched and a getComputedStyle probe silently read empty.
3. buildHtmlLookup undercounted descendants by one.
`count` backs el.querySelectorAll('*').length; that returns DESCENDANTS and
countHtmlElements already skips the #document-fragment root, so the `- 1` was
wrong. Chromium reports 3 for '<li><div></li><li></div'; we reported 2, and a
variant multiplies innerHTML.length by that count.
4. Browser-fidelity probes.
Newer challenge variants assert JS/DOM invariants a flat stub cannot satisfy:
real prototype chains (HTMLDivElement -> HTMLElement -> Element), NodeList
identity, a live body.children HTMLCollection, native-code toString, and
sloppy-mode `this === window`. Nine of thirteen failed. Notably Math must NOT
be sealed — Chromium reports Object.isSealed(Math) === false, and sealing it
made our vector differ by one.
5. The solved payload dropped meta.origin / meta.stack / meta.duration.
The duck.ai bundle always sends all three; captured browser requests confirm
it. Without them upstream returns 418 even when every client_hash is correct.
6. reasoningEffort is now mandatory on duckchat/v1/chat.
An otherwise byte-identical payload returns 200 with the field and 400
ERR_BAD_REQUEST without it (A/B verified live, repeated).
Also removes the throwaway "seed" chat POST that ran before every real request.
It existed to coax a usable challenge out of the upstream while the solver was
broken; it only doubled chat calls against an IP-rate-limited endpoint, showing
up as spurious 429 ERR_RATE_LIMIT.
Verification: the solver now reproduces real Chromium's probe vectors exactly
for all 8 captured challenge variants, and the executor returns 200 end-to-end
live (non-streaming, streaming, claude-haiku-4-5, and a math prompt returning
"42").
Tests: tests/unit/duckduckgo-challenge-solver-regression.test.ts (32 tests) and
tests/unit/duckduckgo-reasoning-effort-required.test.ts (5 tests), backed by
tests/fixtures/duckduckgo/challenge-variants.json — real captured challenge
programs plus the probe vectors a real browser produced for them, so the suite
asserts against recorded browser behaviour rather than our own output. Each fix
was confirmed to fail its test when individually reverted.
Co-authored-by: Mynacol <git@mynacol.xyz>
requestLogger.ts's cloneBoundedForLog had its own hardcoded depth cap of 6,
independent of the existing configurable getChatLogMaxDepth(). A typical
Chat Completions response body's responseBody.choices[0].message.tool_calls[0].function
sits at exactly depth 6, so every logged tool call's function field
(name+arguments) was silently replaced with the literal string "[MaxDepth]"
before ever being stored — corrupting the data, not just how it renders.
Bumped the shared default 6->20 and switched requestLogger.ts to read it
instead of using its own literal.
(cherry picked from commit a2df6cf289)
Co-authored-by: Markus Hartung <mail@hartmark.se>
* feat(logging): bump CHAT_LOG_ARRAY_TAIL_ITEMS default 24 -> 128
Real agentic CLIs with many MCP servers routinely declare 40-50+ tools in
a single request — a live OpenClaw session logged 47. The tail-24 default
silently dropped the array's earlier entries behind an
_omniroute_truncated_array marker, so investigating why a specific tool
call (apply_patch) behaved oddly turned up nothing: its declared shape
(function vs custom type) was unrecoverable from the call log across 40
recent requests, even though the calls themselves succeeded.
Bumped the configurable default to comfortably cover real large tool
lists with headroom. Updated .env.example and docs/reference/
ENVIRONMENT.md to match (env-doc-sync check passes).
* test(logging): pin CHAT_LOG_ARRAY_TAIL_ITEMS default at 128
The bump commit had no dedicated test asserting the literal default
value; the existing chatcore-log-truncation.test.ts derives its
expectations from getChatLogArrayTailItems() itself, so it can't
discriminate a regression back toward the old, too-small 24 default.
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
* feat(logging): make the chat-log truncation limit configurable, bumped default 128x
The 8KB cap on logged request/response bodies
(open-sse/handlers/chatCore/logTruncation.ts::truncateForLog()) was
hardcoded — trivially exceeded by any real multi-turn agentic
conversation, meaning the dashboard's "Full Conversation" panel could
only ever show a placeholder instead of the actual messages for nearly
every logged row of any conversation with real substance.
- Added CHAT_LOG_MAX_BODY_KB env var (src/lib/logEnv.ts::
getChatLogMaxBodyBytes()), default 1024 KB (1MB) — a 128x bump from
the old hardcoded 8KB — following the same configurable-limit pattern
as the sibling CHAT_LOG_TEXT_LIMIT/CHAT_LOG_ARRAY_TAIL_ITEMS/etc. vars.
- Documented in .env.example and docs/reference/ENVIRONMENT.md.
estimateSizeFast() (open-sse/utils/estimateSize.ts) has been
substantially rewritten upstream since this bug was first found (now an
iterative Frame-based walker with a separate node-visit budget, not the
simple stack loop originally patched) — re-implemented the fix against
the current algorithm rather than porting the old diff: the byte
early-exit was unconditionally the module-level ESTIMATE_SIZE_BYTE_LIMIT
(256 KiB) with no way for a caller to raise it, so any caller comparing
against a bigger configured threshold could never see a size above
~256 KiB — every payload between 256 KiB and the caller's real limit
looked "under threshold" and truncation never fired, the opposite of
intended. Added an optional byteLimit parameter (default unchanged at
ESTIMATE_SIZE_BYTE_LIMIT, so isSmallEnoughForSemanticCache's existing
behavior is untouched) threaded through both the byte-check early-exit
and the node-budget-exhaustion fail-closed fallback, with
truncateForLog() now passing its own configured getChatLogMaxBodyBytes()
value through.
* feat(dashboard): show conversation session tag in request detail metadata
Adds a "Conversation" field to the request detail panel's metadata
grid (after "Combo"), showing the request's conversation id
(sessionTag) for quick reference/copy.
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
* fix(responses-api): sync reasoning-cache write index with the fixed read side
The turn-index-hardcoding fix updated the reasoning-cache read side
(translator/index.ts's main replay loop) to key lookups by the assistant
message's real position in the messages array, but two other spots still
used the old hardcoded convention:
- chatCore.ts's write side (both the streaming and non-streaming
completion paths) still cached every response under a hardcoded
messageIndex: 0.
- translator/index.ts's own plain-turn (non-tool-call) cache-key lookup
ALSO still hardcoded messageIndex 0 at its call site — a second,
previously undiscovered instance of the same class of bug, found while
re-verifying this fix against the current upstream tip (the original
fix only addressed the write side).
Past the first assistant turn these conventions no longer matched, so
DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the
cache and fell back to the placeholder (or, once #9573 removed the
placeholder fallback, to an absent field) in ordinary multi-turn
conversations.
Compute the write-side index from the incoming request's message count
instead, and use the real loop-provided messageIndex on the read-side
lookup, both matching the position the response occupies once the
client appends it to history for the next turn.
Note: this was originally part of a larger squashed fix (output_index
collision prevention across reasoning/message/tool_call items,
reasoning-content-alias generalization) that has since been superseded
by upstream's own independent fix — translator/response/openai-responses.ts
now has its own dense-output-index-sort + getReadableReasoningValue
implementation (own comment: "mirrors upstream PR #721"). Only this
narrower, still-genuinely-broken write/read index sync survives as a
distinct bug.
Test plan:
- TDD: tests/unit/reasoning-cache.test.ts's new end-to-end
"write side (chatCore's messageIndex) and read side (translateRequest)
agree on the same key end-to-end" test, plus the pre-existing
"should inject placeholder for a plain (non-tool-call) DeepSeek turn"
and "should replay cached reasoning for a plain (non-tool-call)
DeepSeek turn when available" tests — confirmed failing against the
pre-fix code on a clean release/v3.8.50 checkout (both the
hardcoded-0 write side AND the hardcoded-0 read-side lookup
independently reproduce the mismatch), passing after both fixes
- npm run typecheck:core — clean
- npm run lint — clean
- npm run check:file-size — clean (chatCore.ts rebaselined 5034->5042
for the messageIndex computation at both call sites;
reasoning-cache.test.ts frozen at 1035, matching the original fix's
own rebaseline)
- 2 pre-existing, unrelated test failures in the same file
("should replace empty-string reasoning_content with
NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss",
"should inject placeholder for a plain (non-tool-call) DeepSeek turn
missing reasoning_content") confirmed present on a completely clean,
untouched release/v3.8.50 checkout — these test obsolete
placeholder-injection behavior the code deliberately removed per
#9573 (see the code's own comment); not touched by this PR
* fix(chat): reduce file size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(chat): reconcile file-size baseline
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
* test(integration): add general live-test tool for the real "default" combo
Temporary WIP commit on this deferred branch — lands in its own separate
PR once the bug-fix extraction batch is done (never bundled into a
bug-fix PR). Unlike liveGeminiShared.ts (provisions its own narrow
2-model Gemini-only combo), this reads the REAL "default" combo
currently configured on the target instance directly from the DB and
exercises every provider/model step in it directly, bypassing combo
routing, so live-test coverage always matches whatever is actually
configured instead of a hardcoded snapshot.
Live-verified against omniroute-beta (seeded with the real 18-model,
5-provider default combo): 14/18 models pass consistently across
non-streaming + streaming Chat Completions and streaming Responses API.
The 4 consistent failures are real external state (cerebras
credits_exhausted, one deprecated openrouter free-tier model), not code
regressions.
(cherry picked from commit c40b13a48fd897259c56f5122e9e57a3dc7654ba)
* test(integration): add rootless wire-capture correlation to the live-test tool
Temporary WIP commit on this deferred branch — lands in the same final
live-test-tool PR as the general default-combo suite, never bundled into
a bug-fix PR.
liveContainerHarness.ts spins up a dedicated, throwaway podman container
(same runner-base image target as the operator's local dev/beta
containers) so wire-capture tests are fully self-contained: builds the
image if missing, starts the container with a persistent data dir, waits
for health, seeds the real "default" combo + provider connections from
the operator's local omniroute-dev instance (idempotent — only runs once
per data dir), and provisions API keys via the running instance's own
auth flow.
wireCapture.ts captures the container's actual network traffic via
`podman unshare nsenter --net=<container netns> -- tcpdump` — no root
needed, verified working live (this generalizes the root-requiring
`sudo nsenter -t $PID` command scripts/sre/tcp-close-analyzer.py already
documented for the same rootless-Podman netns problem; that script's
docstring now documents both). Capture and analysis needed two real fixes
found only by running the pipeline live: `-U` (unbuffered tcpdump writes)
plus a `pkill -f <pcap path>` fallback, since `podman unshare -> nsenter
-> tcpdump` is a 3-level subprocess chain and SIGTERM to the top-level
process doesn't reach the tcpdump grandchild, leaving an orphaned process
and a truncated/unreadable pcap; and filtering on the container's
internal listening port (20128) rather than the dynamically-assigned host
port, since capture happens inside the container's own network namespace
where only the internal port is meaningful.
live-default-combo-wire-capture.test.ts (gated on RUN_LIVE_WIRE_CAPTURE=1)
ties it together: sends a small representative sample of requests through
the real default combo, then cross-checks each one's app-level JSON
status against the actual HTTP status line observed on the wire via
scripts/sre/tcp-close-analyzer.py's stream reassembly — catching bugs
where the app layer claims success but the wire shows a
truncated/reset stream, not just what liveDefaultComboShared.ts's
existing breadth suite already covers.
Live-verified end-to-end: 4/4 sampled requests correlated correctly
across 8 captured TCP streams, container + capture process fully torn
down afterward (verified no orphaned podman container or tcpdump
process left running).
sendModelRequest/filterActiveModelTargets (liveDefaultComboShared.ts) gain
optional baseUrl/apiKey overrides, defaulting to the existing module-level
omniroute-beta target, so the wire-capture suite can point the same
request-sending logic at its own dedicated container instead.
(cherry picked from commit 914a7e42cbe914f257db9f72eedc902ee1532083)
---------
Co-authored-by: Markus Hartung <mail@hartmark.se>
* chore(repo): ignore Electron build output unpacked into repo root
electron-builder (squirrel-windows target) unpacks the packaged app -- the
entire Chromium runtime, ~24k files -- directly into the repository root:
OmniRoute.exe, chrome_*.pak, *.dll, locales/, resources/, icudtl.dat,
snapshot blobs and the Chromium license files.
None of it was covered by .gitignore, so `git add -A` would commit the whole
runtime. Every rule is root-anchored (leading `/`) because a bare `locales/`
or `resources/` would also swallow tracked sources -- notably the CLI
translations in bin/cli/locales/*.json.
Verified with `git check-ignore`: all artifact paths ignored, and
bin/cli/locales/{en,de}.json remain tracked.
* chore(electron): sync package-lock for windows installer deps
Adds the lockfile entries for the Windows installer/signing toolchain that
the electron build now pulls in: electron-builder-squirrel-windows,
electron-winstaller and @electron/windows-sign (plus their transitive
fs-extra/jsonfile/universalify/mkdirp pins), and bumps app-builder-lib and
builder-util-runtime.
Lockfile-only change; no source or runtime behaviour is affected.
---------
Co-authored-by: Mihaly Bodo <michael@proton-quantum.com>
The provider-connection dialog (AddApiKeyModal / EditConnectionModal)
rendered humanized key names instead of real copy for
providers.validationModelId{Label,Placeholder,Hint} in 34 of 43 locales —
the values read "Validation Model Id Label", "Validation Model Id
Placeholder" and "Validation Model Id Hint" verbatim.
Each translation follows the terminology and register already used by the
neighbouring provider keys in its own file — e.g. de Anbieter/API-Schlüssel
with formal Sie, fr fournisseur/clé API, ru провайдер/ключ API — and each
locale's own "e.g." convention (z. B., 例:, напр., ör., cth., hal.).
Source of truth is en.json, which labels the field "Validation Model"
(no "ID"); a few older locales say "validation model ID" and were left
untouched rather than propagating that divergence.
Co-authored-by: Mihaly Bodo <michael@proton-quantum.com>
The /v1/models catalog mirrors `claude/<provider>/<model>` ids purely from the
alias gate -- ccAliasPredicate.ts consults no provider registry. The request
path additionally required the prefix to be an open-sse REGISTRY entry or an
operator-defined custom node.
Enterprise-cloud providers such as azure-ai / azure-openai live only in the
provider catalog (src/shared/constants/providers/apikey/enterprise-cloud.ts).
They route fine directly -- `azure-ai/Phi-4` returns 200 -- but have no
open-sse registry entry, so the two sides disagreed: the catalog advertised
`claude/azure-ai/<model>` while stripCcDiscoveryAlias refused to strip it.
The unstripped id then fell through to normal resolution, which splits on the
first / and parsed `claude` as the provider. Every Claude Code request for an
Azure model was routed to the Claude provider instead:
ROUTING: Provider: claude, Model: azure-ai/DeepSeek-V4-Flash
Extract the predicate as `isRoutableProviderPrefix()` and widen it to the
provider catalog (id + alias) alongside the open-sse registry, so the request
path recognises exactly what the catalog can advertise.
Regression guard: tests/unit/cc-discovery-alias-routable-prefix.test.ts pins
azure-ai/azure-openai/azure as routable, keeps openai/anthropic routable, and
keeps an unknown prefix non-routable. Verified failing before the widening.
Co-authored-by: Mihaly Bodo <michael@proton-quantum.com>
* fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main
Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici)
applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies,
and mermaid.
npm audit: 6→0 vulnerabilities.
Closes Dependabot #161-#166.
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)
Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.
Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.
npm audit → 0 vulnerabilities.
* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)
_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.
* fix(translator): keep Responses namespace identity across the hub-and-spoke pivot
Step 1 of the pivot (openai-responses -> openai) flattens namespace sub-tools
to a qualified wire name (#8295) and records the `{namespace, name}` pair on a
non-enumerable `_toolNameMap`. Step 2 (openai -> target) returns a brand-new
object, so the property was dropped for every non-OpenAI target. chatCore then
handed `null` to the #7936 response seam and namespace sub-tool calls reached
the client under their flattened name, which Codex rejects with
`unsupported call: <name>` — the symptom #7936 was opened to fix.
Copying `_toolNameMap` through is not viable: openai-to-claude and
openai-to-gemini publish their own `Map<string, string>` alias map on that same
property during step 2, so it carries two incompatible types. This adds a
dedicated `_namespaceToolIdentityMap`, propagated by translateRequest across
the pivot; chatCore prefers it and falls back to `_toolNameMap` for the
non-pivot producers. Both keys are stripped from the cliproxyapi wire body.
Fixes#9780
* fix(chat): reduce file size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(chat): reduce combined file size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(chat): reduce combined file size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: VXNCXNX <vincent@preuve.ai>
* fix(sse): apply Azure request-param rules on the azure-ai wire path
Azure rejects several stock Chat Completions params on its newer deployments
and returns HTTP 400 rather than ignoring them:
max_tokens -> 'max_tokens' is not supported with this model.
Use 'max_completion_tokens' instead.
reasoning_effort -> Function tools with reasoning_effort are not supported.
Those rules lived inline in AzureOpenAIExecutor, so they only covered the
azure-openai provider. azure-ai (Azure AI Foundry) had no executor entry and
fell through to the bare DefaultExecutor, so the SAME Azure deployment
succeeded on one connection and 400'd on the other. Every agentic client sends
tools on every turn, so azure-ai failed on the first request.
Extract the rules to open-sse/executors/azureParamRules.ts, add an
AzureAiExecutor that inherits DefaultExecutor's azure-ai URL/header/apiType
handling unchanged and applies the shared rules, and register it for azure-ai.
Also widen the deployment pattern to cover gpt-chat-latest: it is a moving
alias that resolves to a GPT-5-era model and rejects max_tokens, but carries no
version number for the token-boundary pattern to key on. Verified against the
base regex - gpt-chat-latest did not match, which is exactly the observed 400.
Regression guard: tests/unit/azure-param-rules.test.ts, including an assertion
that getExecutor("azure-ai") no longer resolves to a bare DefaultExecutor.
* fix(sse): clamp Azure gpt-4o-mini completion tokens to its 16384 ceiling
Azure gpt-4o-mini deployments accept at most 16384 completion tokens and 400 on
anything larger:
max_tokens is too large: 32000. This model supports at most 16384 completion
tokens, whereas you provided 32000.
The 32000 is OmniRoute's own doing: adjustMaxTokens raises any smaller
max_tokens to DEFAULT_MIN_TOKENS (32000) whenever tools are present, to avoid
truncated tool arguments. That floor has no upper bound, so an agentic client
asking for far less still trips the model ceiling on its first turn.
Add scoped maxOutputCap rules in paramSupport.ts for both Azure wire paths.
PROVIDER_MAX_TOKENS is the wrong lever here - it is provider-wide, and the same
Azure resource also serves GPT-5 deployments with a much higher ceiling.
Regression guard: tests/unit/azure-max-output-clamp.test.ts, which also pins
that the clamp does not leak to gpt-5.1 or to gpt-4o-mini on other providers.
---------
Co-authored-by: Mihaly Bodo <michael@proton-quantum.com>
copyOpenAICompatibleReasoningFields only stripped the sentinel
(NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary
unavailable)") from reasoning_content and reasoning. Non-standard
reasoning fields (reasoning_text, thinking, thought) and
reasoning_details items passed through raw, leaking the internal
replay sentinel to clients on providers that use those fields
(e.g. Venice), where the model echo surfaces as a bogus thought block
and can degrade into empty turns.
Strip the sentinel from every forwarded reasoning field, including
per-item text/content inside reasoning_details; drop items/fields that
strip to nothing while preserving non-text details such as
reasoning.encrypted.
Fixes#9765
Refs #8081, #9606
Co-authored-by: safeer <asafeer1994@gmail.com>