* fix(sse): compact Responses multi-turn images before context hard-reject (#8560)
Codex Desktop sessions near the 372k input cap were rejected on the second
inline image because compressContext no-op'd on Responses input[] and never
pruned older vision turns. Adapt via bodyAdapter, prune older images while
keeping the latest, and run last-resort compaction before the budget check.
* docs(env): document CONTEXT_KEEP_LATEST_IMAGES for #8560
Keep check:env-doc-sync green after the context image-pruning override.
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
#8593 registered bin/cli/utils/ensureAndroidCacheDir.mjs in
PACK_ARTIFACT_REQUIRED_PATHS so the Android/Termux cache module cannot silently
drop out of the npm tarball. Three test files read that list; only
pack-artifact-entrypoint-closures.test.ts derives it dynamically. This one
hardcodes the expected set, so it went red on a correct change.
Adding the entry here, not relaxing the assertion — a hardcoded fixture is what
makes an accidental REMOVAL from the required-paths list loud, which is the
whole point of the guard.
* refactor(sse): extract combo dispatch prelude into combo/dispatchPrelude.ts
Pure move, no behaviour change. First of ~7 PRs decomposing the combo.ts
god-file (#3501).
handleComboChat evaluates a series of dispatch branches before it ever
reaches target resolution or the sequential attempt loop. None of them
iterate targets in priority order or need the failover/retry/credential
gate machinery that follows, so they move to a leaf:
- context-cache pin routing (Fix#679), including the
pinIsDurablyUnhealthy / isPinnedModelDurablyUnhealthy health gate
- fusion panel dispatch + the #6455 misconfiguration warn
- pipeline chaining
- nested combo-ref execute-mode runtime-unit dispatch
Only the chaos and round-robin hand-offs stay inline (11 and 13 lines);
extracting those would be pure indirection.
open-sse/services/combo.ts 3642 -> 3341 (-301)
open-sse/services/combo/dispatchPrelude.ts: 619 (under the 800 cap)
Each helper keeps the fall-through protocol the inline blocks had: return
a Response to OWN the request, return null to fall through. A flipped
null/Response would silently bypass the whole combo strategy, so the new
tests pin both directions for every branch.
combo.ts re-exports pinIsDurablyUnhealthy so combo-pin-health-gate.test.ts
keeps resolving. The leaf takes handleComboChat as a `runCombo` parameter
instead of importing it, so combo/ keeps zero back-edges into combo.ts.
Complexity-neutral: the first cut added +3 violations (two
max-lines-per-function, one complexity) inside the new leaf, so
evaluatePinnedResponse, orderRuntimeUnits, recordRuntimeUnitStickySuccess
and buildBaseOptions were split out. check:complexity now measures 2169
and check:cognitive-complexity 956 — identical to the pristine base.
* test(sse): close the dispatch-prelude coverage holes found by mutation testing
An adversarial mutation audit of the suite added in the previous commit
found it guarded the fall-through protocol well but asserted almost
nothing about what the helpers do once they OWN the request. 5 of 12
seeded mutations survived. Worst case: deleting the pinned-model
dispatch call outright left all 12 tests green.
Three holes, now closed (8 tests -> 20):
Hole A — the honored-pin path had zero coverage. Both existing pin tests
DROP the pin, so the dispatch, the 200-but-empty quality gate, the
[408, 429, 500, 502, 503, 504] failover list and the catch(pinErr)
branch were unguarded — exactly the logic the 2026-06-21 / 2026-06-22
incident comments call load-bearing. Adds five tests over a seeded
healthy provider connection so the pin is actually honored.
Hole B — orderRuntimeUnits was only ever driven with `priority`, which
is a no-op through it. Four of five strategy branches could be deleted
with nothing failing. Adds round-robin rotation and weighted sticky
ordering tests.
Hole C — recordRuntimeUnitStickySuccess never did anything under test:
both its guards need weighted/round-robin, so an early return changed
nothing. Covered by the new sticky-batch test.
Verified by re-running the mutations rather than assuming: all 7 that
previously survived (delete-pin-dispatch, serve-despite-failed-quality,
never-fail-over-on-transient, rr-counter-not-advanced, rotation-removed,
weighted-sticky-skipped, sticky-recording-no-op) are now killed.
The first sticky-batch test I wrote was itself vacuous — asserting "same
unit twice" holds equally when the recording helper is stubbed out, since
nothing advances the counter either. It now asserts the batch runs out
and rotation resumes on the third dispatch, which is what actually
distinguishes the two.
Also restores API_KEY_SECRET in test.after; it was set at module load and
never put back, inconsistent with the DATA_DIR handling beside it.
* fix(ci): teach known-symbols gate the relocated fusion/pipeline dispatch
The combo sub-check of check:known-symbols asserts every canonical routing
strategy has a real dispatch branch. It scanned a hardcoded file list and
matched only `strategy === "..."`, so the prelude extraction tripped it twice:
[combo] 2 estratégia(s) canônica(s) sem branch de despacho em combo.ts:
✗ fusion
✗ pipeline
Both branches are still wired — they just moved to combo/dispatchPrelude.ts and
took the early-return guard form `if (strategy !== "fusion") return null;` that
extracting a branch into a `tryXDispatch()` leaf naturally produces.
Two changes, both extending existing precedent (the list already carries the
Block J leaves for the same reason):
- register combo/dispatchPrelude.ts in comboDispatchFiles
- widen the extractor to `strategy [!=]== "..."` so the inverted guard counts
Loose `==`/`!=` stay rejected, and no `handledNotCanonical` fallout: the gate
now reports 20 canonical strategies, all 20 via despacho.
* chore(ci): register combo-dispatch-prelude test in stryker tap.testFiles
check:mutation-test-coverage --strict failed once the known-symbols fix let
Fast Quality Gates advance to it:
✗ 2 covering unit test(s) across 2 module(s) are missing from
stryker.conf.json tap.testFiles
open-sse/services/combo/comboStructure.ts
open-sse/services/combo/rrState.ts
The new tests/unit/combo-dispatch-prelude.test.ts exercises both modules, and
both are already in stryker's mutate list, so without the registration its
mutant kills would not have counted toward the nightly mutation gate.
Note (unchanged, still out of scope): combo/dispatchPrelude.ts itself is not in
stryker's `mutate` list. Adding it would widen the nightly mutation surface,
which is a separate call from fixing this drift.
* docs(changelog): add fragment for #8582 combo dispatch prelude
* refactor(sse): extract combo target resolution into combo/targetResolution.ts
Pure move, no behaviour change. Lifts the target-resolution stage of
handleComboChat — everything between the dispatch prelude and the attempt
loop — into a new leaf, open-sse/services/combo/targetResolution.ts.
Moved verbatim: provider-wildcard expansion, weighted step-group resolution
+ sticky-weighted eligibility, request-tag routing, the known-context-overflow
early return, the smart/pipeline-enabled auto dispatch, auto-strategy
ordering, per-strategy ordering, cache-strategy affinity, session stickiness,
eval scores, request-compatibility + context-requirement filters, task-aware
reordering, prompt-cache affinity, and the priority-strategy pre-screen.
The three early exits become an { earlyResponse } result so the host decides
to return them (same pattern as resolveAutoStrategyOrder). The values the
attempt loop still reads — orderedTargets, stickyWeightedLimit,
getWeightedStepKeyForTarget, the session-stickiness result and preScreenMap —
are returned instead of closed over. Loop config (maxRetries, retryDelayMs,
fallbackDelayMs, maxSetRetries, setRetryDelayMs) stays in combo.ts.
buildAutoCandidates is dependency-injected because it lives in combo.ts, so
the leaf keeps zero back-edges into its host.
combo.ts 3640 -> 3321 lines; new leaf 484 lines (under the 800 cap).
Part of the #3501 god-file decomposition campaign.
* refactor(sse): split targetResolution into stage helpers, ratchet combo.ts file-size baseline
Follow-up to the target-resolution extraction: the moved region landed as one
311-line function, which converted inline code inside the (already-violating)
handleComboChat into a NEW separately-counted violating function — check:complexity
2169 -> 2171 and check:cognitive-complexity 956 -> 957.
Split resolveComboTargetPipeline along its natural stage boundaries into 14
helpers (wildcard expansion, weighted eviction/eligibility/sticky-key/selection,
step-key mapper, context-overflow response, pool-size log, smart-pipeline dispatch
and its fall-through logger, strategy ordering, continuity filters, task-aware
ordering, prompt-cache enablement/first-target protection/affinity stage). Each
stage takes the previous stage's output and returns the next; still a pure move.
The leaf now contributes ZERO complexity, max-lines-per-function and
cognitive-complexity violations. Both ratchets are back at base 4053e2314 values:
check:complexity 2169, check:cognitive-complexity 956. (Both still print RED
against their frozen ceilings 2130/951 — pre-existing base-red per #8580.)
Also ratchets ONLY the open-sse/services/combo.ts entry in
config/quality/file-size-baseline.json from 3642 to 3322, with a justification
note in the file's existing style. No sweep of unrelated entries.
* chore: stack targetResolution on dispatchPrelude tip, rebank + skills
Rebased onto refactor/combo-dispatch-prelude. Keep both leaves in
check-known-symbols. Regenerate file-size baseline; sync agent skills.
* fix(sse): restore #8494 capability fail-closed after targetResolution extract
Stacking targetResolution onto the dispatchPrelude tip dropped the #8488/#8494
compatFilterFailOpen wiring: hard capability filters emptied the pool into a
generic 404 no_executable_targets, and fail-open never re-admitted the pool.
Restore describeCapabilityFilterExhaustion earlyResponse in
applyContinuityFilters and the matching round-robin path, then rebank the
file-size baseline for tip growth the incomplete prior rebank missed.
* fix(sse): realign model-lockout cooldown options with the post-#8254 type
This branch predates #8254, which renamed the recordModelLockoutFailure option
`exactCooldownVerified` -> `exactCooldownIsUpstreamReset` and changed combo.ts's
predicate from `lockoutHintVerified` (#8393's `lockoutHintMs > 0`) to
`lockoutHintMs > mlSettings.baseCooldownMs`. Rebasing onto the current tip brought
the renamed type without updating these two call sites, so typecheck:core failed
with TS2353 at both.
Restores the base expression verbatim rather than re-wiring `lockoutHintVerified`
under the new name. The base predicate is the correct one: selectLockoutCooldownMs
returns the parsed hint ONLY when `lockoutHintMs > baseCooldownMs`, and otherwise
returns 0 or a synthetic baseCooldownMs — so `lockoutHintMs > 0` would mark a
synthetic cooldown as an upstream reset and let it bypass the #7940 maxCooldownMs
cap, which is the bug #8254 fixed.
---------
Co-authored-by: MumuTW <johnsxn.us@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
file-size: nine frozen entries could not absorb the combined result of the
31-PR train. Two distinct causes, kept apart in the baseline note on purpose:
(1) GENUINE irreducible growth at existing chokepoints —
providerLimits/auth (#8632), rateLimitManager (#8616),
models-catalog-route.test (#8610).
(2) COLLISION with #8585, which banked shrinks measured on the pre-train
release tip while 30 sibling PRs in the SAME train grew those files
again — chat/accountFallback (#8628), chatCore (#8613),
videoGeneration (#8581), imageGeneration.
Ceilings re-pinned to the post-merge tip. #8612 (also in this train) automates
shrink-banking so this self-inflicted drift stops recurring.
stryker: three covering unit tests were missing from tap.testFiles —
isLocalStreamLifecycleError-abort-shape (circuitBreaker.ts, a shared base-red
that was reddening Fast Quality Gates on every open PR),
noauth-autocombo-lockout-7623 (accountFallback.ts) and
kimi-quota-reset-recovery (auth.ts), the latter two landed with this train.
* chore(skills): regenerate cli-backup-sync SKILL.md to match catalog
check:agent-skills-sync was failing on release/v3.8.49 tip because the
generated SKILL.md still documented backup-status flags the catalog no
longer exposes. Re-run generate-agent-skills --apply (9-line delete only).
* docs(changelog): add fragment for #8657 agent-skills sync
Check if model_capabilities table exists in SQLite before running SELECT query in loadModelCapabilities to prevent SQL error when the table has not been created yet.
Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
`handleChatCore` returns a union that includes a bare `Response` alongside the
`{ success, response, … }` envelopes — early returns that never build one.
responsesHandler read `result.success` / `result.response` straight off it, so
three diagnostics fired on the `Response` arm, which has neither.
Added an `instanceof Response` guard before the envelope checks. The outcome is
unchanged: a bare Response already fell through `!result.success` (undefined,
so truthy under `!`) and was returned as-is; it is now returned one branch
earlier, explicitly.
208 -> 205, zero new, on a line-number-agnostic diff of the full tsc error set.
The other two diagnostics in this file are left alone on purpose. Declaring
convertResponsesApiFormat's return type fixes them, but immediately surfaces the
next masked error at the handleChatCore call — `onStreamFailure` is declared
required in that parameter object while responsesHandler has always omitted it
in production. Fixing that means touching chatCore's signature, which belongs
with the chatCore work rather than a three-line guard. Measured 5 fixed / 1 new
and reverted, keeping the zero-new invariant.
No new tests: the guard adds a branch that returns the same value the fall-
through already returned, and the bare-Response path is exercised by the 400
tests in the responses suites, all passing.
Co-authored-by: backryun <busan011@ormbiz.co.kr>
All five diagnostics in src/lib/batches/dispatch.ts are the same:
Type '(request: any, context: any) => Promise<any>' is not assignable to
type 'BatchRouteHandler'.
Target signature provides too few arguments. Expected 2 or more, but got 1.
`withInjectionGuard()` returns `guardedHandler(request, context: any)` with the
second parameter required, so every route it wraps advertises arity 2. The batch
dispatcher's `BatchRouteHandler` is `(request: Request) => …`, and TS rejects
assigning a function that needs an argument the caller will never supply.
The declaration was wrong about its own runtime. `dispatch.ts:48` already calls
`handler(request)` with one argument, and has been doing so in production;
`context` is only forwarded to the inner handler, where routes that do not read
it get `undefined`. Marking it `context?: any` states what was already true.
208 -> 203, zero new, on a line-number-agnostic diff of the full tsc error set.
One character; nothing executable changed.
No test added: the one-argument call path is the existing behaviour and is
already covered — embeddings-auth.test.ts and embeddings-route-apikeymeta-6929
call `POST(req)` directly through withInjectionGuard, which is exactly the arity
this now permits. 370/370 across the 43 injection-guard / batch / embeddings /
moderation suites; typecheck:core, eslint and check:file-size clean.
Co-authored-by: backryun <busan011@ormbiz.co.kr>
* fix(windows): request shell when spawning bare qoder binary name on Windows (fixes#8590)
Post Node CVE-2024-27980, spawn('qodercli', [], { shell: false }) fails with ENOENT on Windows when command is a bare binary name without extension. Enabling shell mode for bare command names allows cmd.exe to resolve .cmd / .bat wrappers from PATH.
* fix(windows): ensure windowsHide: true on open-sse qoder/devin child spawns
---------
Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
* fix(docker): honor OMNIROUTE_BASE_PATH behind reverse-proxy subpaths
Next.js basePath is compile-time state; Docker now records the baked value,
forwards the env var as a build-arg, patches root-path images at container
start when needed, and probes health under the active subpath.
Hard Rule #13: scripts/docker/patch-basepath.sh and the entrypoint invoke Node
with a fixed argv; OMNIROUTE_BASE_PATH is read from process.env only — never
interpolated into sed/awk.
Closes#8600
* fix(docs): unblock CI for Docker basePath guide
Describe the build-time basePath marker as a sentinel file instead of a
fabricated env var, and replace the unsupported ```env fence with bash so
fumadocs/Shiki can compile DOCKER_GUIDE.md during DAST smoke.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(docker): add changelog fragment for #8615 basePath bundle patch
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(ci): automate ratchet shrink-banking on the release branch (#8584)
The quality ratchet is only half automatic, and it is the wrong half. Raising a
cap is a manual JSON edit that takes ten seconds and is the fastest way to unblock
a red PR. Lowering one requires someone to run `--update` and commit the result —
and no workflow does: grepping `.github/workflows/` for `--update` finds only
wiki-sync.yml (unrelated) and ci.yml's check-quality-ratchet.mjs --require-tighten
(a different script against a different metric).
Measured on release/v3.8.49 at 4053e2314: 18 frozen files already at or under the
800-line new-file cap, the worst at 132x (src/shared/validation/schemas.ts, 19
lines carrying a 2,523 cap); the complexity ceiling walked 1794 -> 2169 across ~37
rebaseline notes with exactly one decrease (-1); "tighten via --update next cycle"
written 31 times and honoured once in six weeks. A cap that outlives the code that
earned it converts every completed decomposition into a growth allowance for
whoever edits that file next.
New job `bank-ratchet-shrinks` in nightly-release-green.yml measures the active
release branch, runs the two shrink-only `--update` paths, and opens ONE
always-current PR with the result. Schedule/dispatch only, deliberately not on
push: banking has no latency requirement, while a per-merge run would rebuild the
PR branch during merge campaigns and pay for a full ESLint walk each time.
Detection stays on push (release-green); only banking is batched. The job never
pushes to release/* — a human merges, so a bad measurement cannot land unreviewed.
scripts/quality/verify-ratchet-bank.mjs is the hard guarantee that the automation
can only ever write downward. It diffs the post-`--update` tree against HEAD and
aborts the job before a commit exists — opening no PR — unless every change is a
frozen/testFrozen entry lowered or removed, `count` lowered, or
cognitiveComplexity.value lowered. Raising a number, adding an entry, changing
cap/testCap, or deleting/rewriting a `_rebaseline_*` note all fail. A bot that
could raise a cap would be strictly worse than the status quo.
Verified both directions against the real baselines: --update + verifier reports
77 lowered / 14 removed / nothing raised, exit 0; hand-raising chatCore.ts to 9999
and cap to 1200 is rejected with exit 1. 22 unit tests cover each way the
automation could go wrong.
No product code changes.
* chore(skills): sync cli-backup-sync SKILL.md after rebase onto tip
The `compat-build-26` job in nightly-compat.yml is the only place in the CI
matrix that runs `npm run build` on Node 26 (ci.yml pins CI_NODE_VERSION=24).
It failed every nightly with the runner-reclaimed signature ("The runner has
received a shutdown signal" / "The operation was canceled", no exit code),
always at the same Turbopack compile phase — the classic OOM-kill pattern on
the memory-constrained ubuntu-latest runner.
Root cause: Turbopack's native (Rust, off-V8-heap) allocation is not bounded by
--max-old-space-size and peaks far higher than webpack on OmniRoute's large
module graph (#6409), heavier still under Node 26. Raising the heap does not
help — the codebase's own documented escape hatch for RAM-constrained
environments is the webpack fallback (OMNIROUTE_USE_TURBOPACK=0; see
docs/reference/ENVIRONMENT.md and scripts/build/build-next-isolated.mjs).
Wire that fallback into the Node 26 compat build: it still validates the app
builds on Node 26 (the point of the job) at a much lower memory peak.
Turbopack-on-Node-24 stays covered by ci.yml's build job.
Adds a regression guard (tests/unit/nightly-compat-node26-webpack-8090.test.ts)
asserting the job keeps the webpack fallback so it cannot silently regress.
Class 1 of the triage (shard test failures) was already resolved by #8390,
#8386, #8381, #8383.
Closes#8090
Refs #6949#6409
The OpenCode plugin `features` block (opencode.json) marks every toggle
`.optional()` with no default, and the effective value is applied implicitly
at each read site via the scattered `features.X !== false` (default-ON) /
`features.X === true` (default-OFF) convention. An operator who omits the
`features` block therefore cannot tell whether combos / autoCombos /
enrichment are enabled — they read the `autoCombos=0` startup diagnostic
(a count that can be 0 for reasons unrelated to the flags, e.g. missing auth)
and conclude the features are disabled when they are actually on.
Declare the defaults explicitly in one place and surface the effective flags:
- `OMNIROUTE_FEATURE_DEFAULTS` — the declared default state for every boolean
`features.*` toggle, mirroring the existing read-site conventions exactly
(runtime routing behaviour unchanged).
- `resolveEffectiveFeatureFlags(features)` — derives the effective boolean
state for any (possibly-undefined) features object.
- Startup diagnostics now emit a `features(effective): ...` line so an operator
who omitted the block can see combos/autoCombos/enrichment are on.
Purely additive: no read site changes, so the existing
`features: {} → {}` schema pass-through contract is preserved.
Closes#7624
gemini-web is a stateless Web Cookie provider: it drives a real browser
page and captures only the first StreamGenerate response, so it has no
upstream conversation id to thread across turns. The no-tools path
forwarded only the last user message
(`messages.filter(m => m.role === "user").pop()`), so follow-up questions
lost all prior context — e.g. "I am in Berlin" then "What should I wear
today?" was answered without Berlin.
Implement the issue's accepted fallback (b): flatten the full messages
history into the single prompt typed into the web UI, emitting a labeled
System / Previous conversation / Current user message transcript.
Single-turn requests are preserved byte-for-byte (only the final user
message is returned), keeping the #7286 no-tools regression guard intact.
Applies uniformly to streaming and non-streaming since both derive from
the same `prompt`.
claude-web already threads context via its conversation cache
(session.ts, #8230) and needs no change.
Closes#8371
* fix(auth): tag internal/loopback-origin failed logins in the audit log
Failed dashboard logins (`auth.login.failed`) are emitted only after a
submitted, non-empty password fails verification, and the recorded IP is
accurate. On a single-process deployment with no reverse proxy, a loopback /
private source IP therefore means the attempt genuinely originated on the box
or the LAN (someone browsing http://localhost and mistyping, or a browser
autofill replaying a stale password) — but the Audit Log had no way to
distinguish those from an external intrusion attempt, so they read as
suspicious noise.
Add `classifyIpScope()` to `ipUtils` (loopback / private / public / unknown,
using bounded string-prefix checks — ReDoS-safe) and stamp `sourceScope` +
`internalOrigin` onto the `auth.login.failed` audit metadata so the audit view
can label internal-origin failures distinctly. No change to which events are
written or to IP attribution.
Closes#8336
* test(auth): assert the new origin tags on the failed-login audit event
This PR tags failed logins with sourceScope/internalOrigin, but the
pre-existing admin-audit-events assertion still deepEqual'd the old
two-field metadata shape and broke. The request under test carries a
public x-forwarded-for, so the expected tags are sourceScope: "public"
and internalOrigin: false — the assertion stays strict, it just covers
the fields this PR introduces.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): restore task-aware routing config on restart (#8601)
The T05 Task-Aware Smart Routing config was persisted to settings.taskRouting
by PUT /api/settings/task-routing but never read back, so it silently reverted
to enabled:false + the hardcoded default model map on every restart.
Two root causes, both fixed:
- No boot hydration existed. Adds hydrateTaskRoutingConfig(settings), wired into
src/instrumentation-node.ts next to the Thinking-Budget restore (#5312). It
accepts either the JSON string the route persists or an already-parsed object,
and fails open on malformed values. applyRuntimeSettings does not cover this
key, same as the Global System Prompt (#2470).
- The config lived in a plain module-level `let`, which is duplicated per module
graph — a boot hydration would have landed on the instrumentation graph's copy
and never reached the one src/sse/handlers/chat.ts reads. This is the exact
break #5312 fix-A hit on the VPS. Moves the store to the globalThis pattern
already used by thinkingBudget.ts and systemPrompt.ts.
Runtime stats are never restored from the persisted blob.
Note the hydration is wired into instrumentation-node.ts, not the unused
src/server-init.ts.
* docs(changelog): add fragment for #8604 task-routing boot restore
* fix(sse): route task-aware defaults by intent, guard fitness pattern order (#8602, #8603)
Two related defects in the hand-maintained model-quality tables.
#8602 — DEFAULT_TASK_MODEL_MAP hardcoded literal provider/model ids
(openai/gpt-4o, gemini/gemini-2.5-flash-lite, deepseek/deepseek-chat, ...).
Wrong twice over: the ids rotted by a generation or two, and applyTaskAwareRouting
overwrites body.model directly, so a literal target skipped auto-combo's 13-factor
scoring (quota, circuit-breaker health, cost, latency, stability), connection
cooldown and model lockout — hard-failing for any operator with no connection for
that provider. Refreshing the strings would only reset the rot clock, so the
defaults now name auto/* INTENTS that resolve against the operator's actually
connected backends:
coding -> auto/coding
analysis -> auto/reasoning
vision -> auto/vision
summarization -> auto/chat:fast
background -> auto/chat:cheap
creative and chat stay pass-through. Operators can still pin a specific model via
PUT /api/settings/task-routing; only the shipped defaults change. No provider/model
literal remains in the module.
#8603 — the pattern-shadowing fix LANDED UPSTREAM while this PR was open
(9f5be229b, Train 1D). lookupStaticFitnessTable now ranks patterns longest-first,
so gpt-4o-mini no longer inherits gpt-4o's 0.9 and deepseek-v3.2 no longer inherits
deepseek-v3's 0.85. This PR therefore no longer changes that behaviour — the
upstream scan is kept verbatim.
What remains for #8603 is the regression guard. The resolution chain hits the DB
(user_override / arena_elo / models.dev tier) before reaching layer 4, so asserting
the ordering through getTaskFitness would depend on DB fixture state. The layer is
exposed as getStaticFitnessTableScore and pinned directly by
taskFitness-pattern-order-8603.test.ts (7 cases), so the guarantee survives future
edits to FITNESS_TABLE. Those 7 cases were written against this PR's original
implementation and pass unchanged against the upstream one — independent
confirmation that the two are behaviourally equivalent.
* docs(changelog): add fragment for #8605 task-routing intent + fitness order
When an auto-combo has models[] populated by the operator but
config.auto.candidatePool is empty (the default for combos created
via the dashboard), expandAutoComboCandidatePool silently expands
the candidate pool to every model of every active provider
connection. This overrides the explicit list in models[] and lets
unintended models (e.g. gemini-3.1-flash-lite) win the auto-strategy
scoring contest.
In omniroute@3.8.48 (npm) only one guard exists before the expansion
loop (GUARD A: if (config.auto.candidatePool populated) return
eligibleTargets). The upstream release branch release/v3.8.49 added
a second guard (combo-ref check, PR #7301) but it does not cover
the common pattern where models[] holds explicit kind:"model"
entries. Both gaps share the same root mechanism and the same fix.
The new guard short-circuits whenever models[] is a non-empty array,
covering both kind:"model" entries (the dashboard default) and
kind:"combo-ref" entries (which #7301 already handles). With this
guard in place, the existing combo-ref check becomes redundant; it
is left in place for the minimal-scope surgical fix, and can be
removed in a follow-up cleanup.
Validation (in isolated Docker, 3 providers + 4 controlled combos):
- 3 explicit models, empty candidatePool: pool 60 -> 6
- 1 combo-ref + 2 explicit, empty candidatePool: pool 64 -> 10
- 3 explicit models, populated candidatePool (GUARD A path): 6 -> 6
- empty models[] virtual auto: 57 -> 57 (expansion preserved)
Closes#8597
Co-authored-by: Michael de Souza Marcos <michael.smarcos@hotmail.com>
Pin the unit suite to a temp data directory before imports so getTokenLimit
resolves against the registry fallback instead of a developer models.dev sync.
* fix(cli): ensure `~/.cache` is created and `XDG_CACHE_HOME` is set before Next.js loads on Android/Termux to prevent silent HTTP 500 errors due to instrumentation hook failures (#8519)
* chore(quality): ignore XDG_CACHE_HOME in the env/docs contract scanner
XDG_CACHE_HOME is an XDG Base Directory spec variable set by the OS or the
operator, never OmniRoute product config — the same reason XDG_CONFIG_HOME is
already ignored. The Android/Termux cache-dir preparation added here reads it
to honor an operator-set cache location, which made check-env-doc-sync demand
an .env.example/ENVIRONMENT.md entry for a variable we do not own.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* build(pack): require bin/cli/utils/ensureAndroidCacheDir.mjs in the tarball
bin/omniroute.mjs imports this module at startup to prepare the Next.js cache
dir before serve on Android/Termux. bin/cli/ is only an allowlist PREFIX, so a
file missing from the tarball would not fail the unexpected-paths check — it
would ship a CLI that throws ERR_MODULE_NOT_FOUND on the very platform this
change targets. Registering it makes the absence loud, same guard class as
storageKeyProvision.mjs and versionFastPath.mjs.
Caught by tests/unit/pack-artifact-entrypoint-closures.test.ts in the v3.8.49
merge-train.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Wire NextAuth session-token merge/persist on successful perplexity-web
responses so rotated cookies survive in provider_connections, matching
chatgpt-web behavior.
Filter auto-combo candidates through existing model lockout, connection
cooldown, and terminal testStatus so repeatedly failing no-auth models
are not re-advertised into auto/* pools.
Regenerate file-size-baseline.json with shrink-only `--update` measured on the
current tip (5f365bae7), after #8741 (--update removes at-cap entries) and #8767
(OWNER-APPROVED temporary cap/testCap 800->1000 for the v3.8.50-.54 PREPARE phase)
both landed.
frozen 186 -> 120 entries (75 shrank, 66 removed as redundant)
testFrozen 58 -> 43 entries (19 shrank, 15 removed as redundant)
Shrink-only, verified against the pre-rebase baseline: no entry grew, no entry
was added, every removed entry measures <= cap, and `cap`/`testCap` stay at the
1000 that #8767 set. All `_rebaseline_*` audit notes are preserved verbatim,
including both #8767 relax entries.
The earlier SKILL.md sync and the `--update` removal fix are dropped from this
branch — both landed upstream via #8741.
This change modifies the instantiation of TLSClient in chatgptTlsClient, claudeTlsClient, grokTlsClient, lmarenaTlsClient, notionTlsClient, and perplexityTlsClient to utilize the new buildNativeTlsClientOptions function. This refactor enhances consistency and maintainability across the TLS client implementations.
* refactor(sse): extract combo dispatch prelude into combo/dispatchPrelude.ts
Pure move, no behaviour change. First of ~7 PRs decomposing the combo.ts
god-file (#3501).
handleComboChat evaluates a series of dispatch branches before it ever
reaches target resolution or the sequential attempt loop. None of them
iterate targets in priority order or need the failover/retry/credential
gate machinery that follows, so they move to a leaf:
- context-cache pin routing (Fix#679), including the
pinIsDurablyUnhealthy / isPinnedModelDurablyUnhealthy health gate
- fusion panel dispatch + the #6455 misconfiguration warn
- pipeline chaining
- nested combo-ref execute-mode runtime-unit dispatch
Only the chaos and round-robin hand-offs stay inline (11 and 13 lines);
extracting those would be pure indirection.
open-sse/services/combo.ts 3642 -> 3341 (-301)
open-sse/services/combo/dispatchPrelude.ts: 619 (under the 800 cap)
Each helper keeps the fall-through protocol the inline blocks had: return
a Response to OWN the request, return null to fall through. A flipped
null/Response would silently bypass the whole combo strategy, so the new
tests pin both directions for every branch.
combo.ts re-exports pinIsDurablyUnhealthy so combo-pin-health-gate.test.ts
keeps resolving. The leaf takes handleComboChat as a `runCombo` parameter
instead of importing it, so combo/ keeps zero back-edges into combo.ts.
Complexity-neutral: the first cut added +3 violations (two
max-lines-per-function, one complexity) inside the new leaf, so
evaluatePinnedResponse, orderRuntimeUnits, recordRuntimeUnitStickySuccess
and buildBaseOptions were split out. check:complexity now measures 2169
and check:cognitive-complexity 956 — identical to the pristine base.
* test(sse): close the dispatch-prelude coverage holes found by mutation testing
An adversarial mutation audit of the suite added in the previous commit
found it guarded the fall-through protocol well but asserted almost
nothing about what the helpers do once they OWN the request. 5 of 12
seeded mutations survived. Worst case: deleting the pinned-model
dispatch call outright left all 12 tests green.
Three holes, now closed (8 tests -> 20):
Hole A — the honored-pin path had zero coverage. Both existing pin tests
DROP the pin, so the dispatch, the 200-but-empty quality gate, the
[408, 429, 500, 502, 503, 504] failover list and the catch(pinErr)
branch were unguarded — exactly the logic the 2026-06-21 / 2026-06-22
incident comments call load-bearing. Adds five tests over a seeded
healthy provider connection so the pin is actually honored.
Hole B — orderRuntimeUnits was only ever driven with `priority`, which
is a no-op through it. Four of five strategy branches could be deleted
with nothing failing. Adds round-robin rotation and weighted sticky
ordering tests.
Hole C — recordRuntimeUnitStickySuccess never did anything under test:
both its guards need weighted/round-robin, so an early return changed
nothing. Covered by the new sticky-batch test.
Verified by re-running the mutations rather than assuming: all 7 that
previously survived (delete-pin-dispatch, serve-despite-failed-quality,
never-fail-over-on-transient, rr-counter-not-advanced, rotation-removed,
weighted-sticky-skipped, sticky-recording-no-op) are now killed.
The first sticky-batch test I wrote was itself vacuous — asserting "same
unit twice" holds equally when the recording helper is stubbed out, since
nothing advances the counter either. It now asserts the batch runs out
and rotation resumes on the third dispatch, which is what actually
distinguishes the two.
Also restores API_KEY_SECRET in test.after; it was set at module load and
never put back, inconsistent with the DATA_DIR handling beside it.
* fix(ci): teach known-symbols gate the relocated fusion/pipeline dispatch
The combo sub-check of check:known-symbols asserts every canonical routing
strategy has a real dispatch branch. It scanned a hardcoded file list and
matched only `strategy === "..."`, so the prelude extraction tripped it twice:
[combo] 2 estratégia(s) canônica(s) sem branch de despacho em combo.ts:
✗ fusion
✗ pipeline
Both branches are still wired — they just moved to combo/dispatchPrelude.ts and
took the early-return guard form `if (strategy !== "fusion") return null;` that
extracting a branch into a `tryXDispatch()` leaf naturally produces.
Two changes, both extending existing precedent (the list already carries the
Block J leaves for the same reason):
- register combo/dispatchPrelude.ts in comboDispatchFiles
- widen the extractor to `strategy [!=]== "..."` so the inverted guard counts
Loose `==`/`!=` stay rejected, and no `handledNotCanonical` fallout: the gate
now reports 20 canonical strategies, all 20 via despacho.
* chore(ci): register combo-dispatch-prelude test in stryker tap.testFiles
check:mutation-test-coverage --strict failed once the known-symbols fix let
Fast Quality Gates advance to it:
✗ 2 covering unit test(s) across 2 module(s) are missing from
stryker.conf.json tap.testFiles
open-sse/services/combo/comboStructure.ts
open-sse/services/combo/rrState.ts
The new tests/unit/combo-dispatch-prelude.test.ts exercises both modules, and
both are already in stryker's mutate list, so without the registration its
mutant kills would not have counted toward the nightly mutation gate.
Note (unchanged, still out of scope): combo/dispatchPrelude.ts itself is not in
stryker's `mutate` list. Adding it would widen the nightly mutation surface,
which is a separate call from fixing this drift.
* docs(changelog): add fragment for #8582 combo dispatch prelude
Closes the gap that let #8463 ship. `oauthModal.googleOAuthWarning`'s English value
was rewritten when the Antigravity login helper landed (#5203); 39 of 43 locales kept a
translation of the PREVIOUS English, which told operators to "copy the full URL and
paste it below" — a flow that cannot complete for that provider family. Non-English
users read confident, wrong instructions for months and no gate noticed.
None of the three existing gates can see this class:
- `sync-ui-keys.mjs` only backfills keys that are ABSENT, never ones that are STALE;
- `check-ui-keys-coverage.mjs` counts key PRESENCE, so a stale translation scores as
fully covered (all 43 locales sat at 99.6% throughout);
- `check-translation-drift.mjs` tracks the `docs/i18n/<locale>/**.md` documentation
mirrors — it never reads `src/i18n/messages/*.json` at all. (Its `.i18n-state.json` is
also absent, so it self-skips, but bootstrapping it would not have helped: wrong
surface.)
New gate `scripts/i18n/check-ui-value-drift.mjs` is DIFF-AWARE rather than
baseline-backed: it compares `en.json` at the merge base against the working tree, and
for every key whose English value changed, reports any locale still holding an untouched
translation.
That choice deliberately freezes pre-existing debt — a diff cannot reveal which old
English a long-standing translation came from, so the gate judges only what the current
change touches, and unrelated PRs never pay for historical drift. The alternative, a
per-key hash baseline over 11207 keys, would have cost a ~600 KB generated file (3x the
largest existing baseline) churning on every i18n PR.
Two ways to satisfy it: refresh the translations, or set them to
`__MISSING__:<new english>` so the runtime serves the corrected English (#7258) while
the key queues for translation. When the string's MEANING changes, renaming the key is
better still — a new key cannot inherit a stale translation, which is what #8463 did.
Wired blocking into the `i18n-ui-coverage` job (the `i18n` job is
`continue-on-error: true`, so a gate there could not block anything). That job gains
`fetch-depth: 0` because the gate needs the base ref; without it the gate self-skips with
`base-unresolved`, mirroring `check-openapi-breaking`. `BASE_REF` is passed via `env:`
and reaches git only through `execFileSync` argv — never a shell string.
Verified against the real defect: rewriting an English value with translations left
behind reports exactly 39 stale locales and exits 1; `--warn` exits 0; an unresolvable
base exits 0 with `SKIP reason=base-unresolved`.
Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
synthOpenAIErrorChunk() sets error.type to upstream_empty_response but omits
error.code. The isRequestScopedUpstreamFailure guard only checks type for
context_length_exceeded, so an upstream_empty_response error slips past it.
- Added code: upstream_empty_response to the error object in synthOpenAIErrorChunk()
- Added test assertion verifying error.code matches error.type
This ensures the guard correctly identifies upstream empty responses as
request-scoped failures.
Closes#8469
Passes through request.additionalModelRequestFields in openAIToBedrockConverse so reasoning/thinking options passed from Kiro/Bedrock translators are retained in the AWS Converse payload.
Adds @cf/qwen/qwen2.5-coder-32b-instruct, @cf/meta/llama-3.3-70b-instruct-fp8-fast, @cf/meta/llama-3.2-3b-instruct, @cf/qwen/qwq-32b, @cf/zai-org/glm-4.7-flash, @cf/moonshotai/kimi-k2.6, and @cf/google/gemma-4-26b-a4b-it to freeModelCatalog.data.ts to match the provider registry.
* fix(plugins): delete the host script synchronously and stop two tests leaking child processes
Three related leaks in the plugin child-process lifecycle, found while tracing 27 node
processes on a developer machine.
1. loader.ts removed the generated omniroute-plugin-host-*.mjs with a fire-and-forget
`rm(...).catch(() => {})`. That unlink loses the race against process exit: test:unit
runs with --test-force-exit, which tears the process down before the promise settles,
so every plugin load leaked one temp .mjs into TMPDIR. Measured at 6 files per
full-suite run, 40 accumulated over a handful of local runs. rmSync closes the race;
the throw stays swallowed because an exception raised from a child "exit" handler
would take the server down, and a leftover temp script would not.
2. plugins-manager-lifecycle.test.ts "activates an installed plugin" called activate() --
which spawns the plugin's child process -- but never deactivate(). deactivate() is the
only path that reaches the loader's cleanup(), so the child outlived the test and its
IPC channel kept the test process's event loop alive.
3. plugins-manager-restart-reload-7806.test.ts simulateRestart() deleted the entry from
loadedPlugins without calling cleanup(), dropping the only handle that can kill child
#1. The reload then spawned child #2, and the finally block's deactivate() could reach
only child #2 -- one dangling child per test. A real restart takes the whole process
tree down, so calling cleanup() here is both the faithful simulation and the fix.
Combined effect: a run without --test-force-exit deadlocks. The test process cannot exit
while its child holds the IPC channel open, and the child waits for messages that never
come. Observed as three plugin hosts alive for 4h51m under a runner that never finished.
Validation (Hard Rule #18, TDD): the new plugins-loader.test.ts case fails against the old
async unlink ("must delete the host script synchronously, not on a later tick") and passes
with rmSync. It redirects TMPDIR/TEMP/TMP to a private directory before counting, because
test:unit runs at --test-concurrency=20 and a concurrent file's host scripts would
otherwise land in the counted directory and flake the assertion.
After: 19/19 pass across the three files, 0 temp scripts created, 0 orphan processes.
tests/unit/build/** 334/334; typecheck:core and eslint clean.
* docs(changelog): add fragment for plugin host script sync delete
An `auto/*` combo whose first step lands on an uncredentialed backend returns
HTTP 200 with `finish_reason: "stop"`, `content: null` and `error: null`. The
agent sees a clean empty assistant turn, has no error to stop on, and retries to
its cap.
The non-streaming path already refuses this: `isEmptyContentResponse` rewrites a
200-with-no-content into a 502 "Provider returned empty content", which the combo
layer classifies as a model-level transient and fails over on (#5085). The
streaming path had no equivalent, and neither existing guard covers it:
- `ensureStreamReadiness` is a LIVENESS probe, not a content one. Its failure
message says so — "Stream ended before producing a non-ping SSE event" — and
`hasStreamReadinessSignal` returns true for a bare `delta:{"role":"assistant"}`.
- `createDisconnectAwareStream`'s #7699 branch fires on a MISSING terminal marker
and is scoped to the Claude client format, because for other formats a
marker-less close is genuinely ambiguous.
The reported stream trips neither: OpenAI format, terminates with
`finish_reason: "stop"` and `[DONE]`, contains nothing.
"Completed normally but emitted zero content" is not ambiguous the way a missing
marker is, so this guard is format-agnostic. It reuses `hasUsefulStreamContent`,
which already existed in streamReadiness.ts — exported, correct, and wired to
nothing — and which already counts tool-call-only and reasoning-only output as
real (#2520). A watcher wraps it to handle frames split across network chunks and
to spot the terminal states where emptiness is legitimate, kept in step with
errorClassifier.ts's `LEGIT_EMPTY_OPENAI_FINISH` / `LEGIT_EMPTY_CLAUDE_STOP`:
length, tool_calls, content_filter, max_tokens, tool_use.
Two guards keep it from over-firing, one of which caught a real regression while
building this: the check applies only when bytes were forwarded, and only when
the body actually looked like SSE. A plain JSON completion travels through the
same wrapper and has no `data:` frames, so "no content seen" says nothing about
it — without the SSE gate, four existing stream tests failed.
The `if (done)` branch's reasoning moved into `resolveSilentCloseReason()`, which
also drops `pull` back under the function-length ceiling; cyclomatic lands at
2187 against a baseline of 2188.
This surfaces the error rather than failing over. Failing over would mean holding
every stream until its first content token, since the combo has already returned
leg 1's response by then — a much larger change. Surfacing the error satisfies
the issue's stated expectation ("surface the upstream error OR fail over") and
stops the retry loop, which is the reported harm.
Closes#8649
Previously checked directly, ignoring database feature flag overrides configured via settings/UI. This update routes the check through so DB overrides are respected as intended.
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
* fix(db): preserve node sqlite open semantics
* test(db): keep node sqlite coverage off Bun
* chore(changelog): number native sqlite fallback
* test(db): register node sqlite tests only under Node
- KimiSponsorBanner/RiskNoticeBanner: read the localStorage dismissal flag
via useSyncExternalStore (server snapshot = visible) instead of a
useState lazy initializer, so the first client render matches SSR
(which has no localStorage) instead of diverging on hydration.
- usage/analytics route: key the per-model aggregation map by model name
alone instead of `${provider}::${model}`, matching the table's one-row-
per-model display and eliminating duplicate `key={m.model}` rows when a
model is served through multiple provider connections/accounts.
- CommandPalette: look up existing section/subgroup by id across the whole
list instead of only comparing to the previous item, so sections whose
children interleave root items and groups (e.g. omni-proxy) don't produce
two subgroups sharing the same "_root" key.
- Add the missing `compressionOutputStyle.ponytail` label/description to
all 43 locale message files (present in the style catalog but never
added to any locale, causing a MISSING_MESSAGE crash).
Co-authored-by: Gillz <gillz@Gillzs-MacBook-Pro.local>