Compare commits

..

42 Commits

Author SHA1 Message Date
diegosouzapw
584b7db02f docs(auth): dashboard session = verified JWT + authenticated claim
Refs #13298
2026-09-11 19:00:02 -03:00
diegosouzapw
5d29ac1371 test(auth): session fixtures mint the real login shape (authenticated: true)
Refs #13298
2026-09-11 18:23:08 -03:00
diegosouzapw
28d320b5e3 fix(auth): every auth_token consumer requires the authenticated claim — Cursor CLI tokens are not dashboard sessions
Closes #13298
2026-09-11 17:54:38 -03:00
diegosouzapw
be43641be4 feat(auth): shared dashboard session verifier that requires the authenticated claim
Refs #13298
2026-09-11 17:36:09 -03:00
Dizzle
cfa2fc7548 fix(sse): rotate opencode accounts on transient 5xx (#12975)
Reconciled and merged. This branch was stacked on #12941, which has since landed, so it read as 1484 additions across 16 files and CONFLICTING. I merged the current `release/v3.8.51` into it rather than rewriting your branch: `open-sse/executors/opencode.ts` conflicted in six places where your side was a strict superset of the squashed #12941, and the tip had touched that file through nothing but #12941, so your side was taken whole. The PR now reads as its real 12 files, +839/-25.

The change itself is right: a flapping upstream 5xx aborting the whole agentic chain is exactly the case where rotating to the next healthy account is safe, and keeping it a separate arm from the 400-empty branch matters because that one has to clone-read the body while this one never touches it. Threading `correlationId` through so interleaved requests stay attributable — and never fabricating one when absent — is the right discipline.

Both geo-block regression suites pass alongside the new ones (43/43 across the five opencode test files), which is what proves the conflict resolution preserved #12941's behaviour.

I also tightened the batch's file-size rebaseline here: `src/sse/handlers/chat.ts` needed no bump at all (it lands at 2452, under its existing 2458 freeze) and `open-sse/executors/base.ts` needed only your +2. An earlier measurement had included a local prettier reformat that is not part of this branch.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them.

- `typecheck:core` clean; `check:changelog-integrity` OK
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests
- `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs.

Thanks @maxmad64bis.
2026-09-11 13:57:28 -03:00
Dizzle
a19bb2227f fix(providers): lock opencode model on upstream 400 model-unavailable (#13146)
Scoping the lock to the MODEL rather than the connection is the right layer for a `400 "Model is unavailable"` — a multi-day upstream outage on one model should not darken the account. Good discipline on the two allowlist-adjacent changes: gating the `markAccountUnavailable` branch on `ruleScope === "model"` AND `status === 400` leaves every other status on its existing path, and deliberately not widening `FULL_TEXT_RULE_PROVIDERS` keeps the #10880 egress-bucketed 429 classification intact. Reading the cooldown from the rule instead of a literal at the call site is what makes it self-healing.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them.

- `typecheck:core` clean; `check:changelog-integrity` OK
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests
- `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs.

Thanks @maxmad64bis.
2026-09-11 13:51:24 -03:00
Dizzle
cc4f7ed1c4 fix(logging): prefer the pipeline over the raw bodies in call-log storage and detail enrichment (#13147)
Both halves are right, and shipping them together is justified: the ladder dropping `pipeline` first threw away the upstream answer while keeping a prompt it already had a copy of, and `resolvePreviousResponseState` rebuilds continuation history out of exactly that field. The detail-panel fix has to ride along because the size-limit placeholder is a non-empty string, so fixing the ladder alone would let it overwrite a good payload. Re-checking emptiness per side after reading is the actual bug — `responseBody` being one value for both sides is what let a provider payload show as the client response.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them.

- `typecheck:core` clean; `check:changelog-integrity` OK
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests
- `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs.

Thanks @maxmad64bis.
2026-09-11 13:51:21 -03:00
Dizzle
3156643f6c fix(opencode): require an http(s) baseURL in both OpenCode plugins (#13142)
Real and nasty precisely because it is silent: `z.string().url()` accepts `localhost:20128` as scheme `localhost:` plus a path, every model gets published with an unusable api url, and the failure happens inside the client so the gateway logs show nothing. Backing the option schema, the publish boundary and the snapshot filter with one `isHttpUrl` in v2 is the right call — those three cannot drift apart. Duplicating the predicate in v1 rather than sharing it is also correct, since the two packages ship independently.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them.

- `typecheck:core` clean; `check:changelog-integrity` OK
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests
- `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs.

Thanks @maxmad64bis.
2026-09-11 13:51:17 -03:00
Dizzle
9f0d54a48f fix(combo): parse numeric-epoch rateLimitedUntil in hasFutureRateLimitUntil (#13141)
Correct and well-traced: `rate_limited_until` is TEXT but one write path stores a bare epoch that SQLite coerces to `"1781696905131.0"`, which `new Date()` alone reads as `NaN` — so a still-cooling connection looked available and combo fed it traffic that could only come back 429. Routing both readers through the existing tolerant normalizer is the minimal fix, and keeping unreadable values fail-open is the right default. The #3954/#3995 lineage explains exactly why this function never inherited the normalization.

This PR also carries the batch's file-size rebaseline, since it merges first and the ceiling has to cover every intermediate state.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them.

- `typecheck:core` clean; `check:changelog-integrity` OK
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests
- `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs.

Thanks @maxmad64bis.
2026-09-11 13:51:13 -03:00
anhtahaylove
20abd89d7c fix(acp): bound session output buffers and reset stderr per prompt (#13100)
Keeping the tail is the right direction — `sendPrompt` resolves with the stdout collected since the prompt was written and stderr is read for diagnostics after a failure, so the newest output is what callers actually use. The `[...output truncated...]` marker keeps it from being silent. Resetting `stderrBuffer` alongside `stdoutBuffer` fixes the subtler half: diagnostics for one prompt were carrying stale output from every earlier one.

Verified on the tree that actually ships — your branch merged onto the current tip, which already carries #13096: `appendCapped()` and `settle()` coexist cleanly and all 7 assertions across both ACP test files pass together.

I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:32:51 -03:00
anhtahaylove
e1cfdb5e48 fix(acp): release listeners, timers and sessions on every sendPrompt outcome (#13096)
`acpManager` being a module-level singleton is what turns this from a per-call leak into unbounded growth — the `MaxListenersExceededWarning` at 11 is the visible symptom. Routing every outcome through one `settle()` is the right shape, and deleting the session on the child's own exit fixes the map growth that `getActiveSessions()`'s `alive` filter was hiding.


I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:30:44 -03:00
anhtahaylove
d61b1727cd fix(cli-helper): clear the log stream timeout on the abort path (#13114)
`stop()` is the normal lifecycle for a `follow: true` stream, not an edge case, so the `signal.aborted` early return skipping `clearTimeout` leaked one armed timer per stop. Cancelling the reader on the early loop exit closes the second half.


I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:30:38 -03:00
anhtahaylove
99fb441434 fix(db): release process listeners when a node:sqlite adapter closes (#13109)
Right precedent — #7494 fixed exactly this for the sql.js adapter and the `node:sqlite` one never got the same treatment, even though it is the default driver whenever better-sqlite3 is unavailable. `/api/db-backups/import` opening a throwaway adapter per request makes it reachable.


I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:30:32 -03:00
anhtahaylove
67618978b0 fix(gamification): close the badge SSE stream when the signal is already aborted (#13106)
Correct: an `abort` listener registered on an already-aborted signal never fires, and `safeEnqueue` can't save it because enqueuing into an unread stream only buffers. The abort-later test earning its keep as a guard on the healthy path is the right instinct.


I reformatted the changelog fragment to the `changelog.d` convention (`- **fix(scope):** …`) before merging — `check:changelog-integrity` rejects a fragment that does not start with a markdown bullet, which is the same gate your #13158 was about. Wording is yours, unchanged in substance.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:30:27 -03:00
anhtahaylove
85a5126dba fix(compression): terminate idle workers on eviction (#13091)
`remove(slot, false)` dropped the slot without `terminate()`, leaving the OS thread and its heap alive — invisible to RSS, which is why 55 orphaned `MessagePort`s took 16 h to surface. Removing the parameter rather than keeping it is the correct call: the pool was its only owner.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:45 -03:00
anhtahaylove
b1733d3c83 fix(plugins): make SIGKILL escalation idempotent per child (#13092)
`once` only detaches when exit actually fires, so a plugin trapping SIGTERM accumulated one listener and one timer per hook timeout. Keying idempotence on the child via a `WeakSet` is right — a second SIGKILL timer would only re-signal a corpse.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:42 -03:00
anhtahaylove
66330cc724 fix(compression): pass a URL object when spawning the LLMLingua worker (#13093)
`node:worker_threads` only treats a `URL` instance as a `file:` URL — a string must be a relative path. The silent `catch {}` in `pump()` made this degrade compression to a passthrough while still reporting success, which is the worst shape for it.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:38 -03:00
anhtahaylove
a3fa6cf524 fix(traffic-inspector): release WS subscriber and ping timer on a dead socket (#13155)
Attaching `close`/`error` before subscribing closes the window where a resource is held with no live cleanup path, and the destroyed-socket re-check after the handshake covers the in-flight case. `write()` not throwing synchronously is exactly why the old `try/catch` never fired.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:34 -03:00
anhtahaylove
30c96d43a5 fix(telegram): bound the per-user API key cache (#13166)
`resolveUserApiKey()` keyed an uncapped Map on an id taken straight from the webhook body. The LRU's recency test is what keeps this from regressing into a clear-when-full cache.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:30 -03:00
anhtahaylove
658153c7b0 fix(stream): release the upstream body when the JSON-to-SSE sniff times out (#13171)
The sniff loop abandoned the upstream reader when `withBodyTimeout()` rejected. The `handedOff` flag correctly spares the two success paths from cancellation; the second test guards that direction.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:26 -03:00
anhtahaylove
a1b9b02d5d fix(telegram): authenticate webhook deliveries with the Telegram secret token (#13175)
Real abuse vector: the bot-webhook branch reached `proxyChat()` — which mints an API key and spends upstream quota — with nothing proving the caller was Telegram. Fail-closed 503 when the secret is unset is the right default.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:23 -03:00
anhtahaylove
178d25250a fix(test): cap local unit-test concurrency at 4 to avoid exhausting commit charge (#13187)
Aligns the two hand-typed local scripts with `test:unit:ci`, which already ran at concurrency 4; `--test-force-exit` was likewise the one flag `test` was missing. CI scripts are untouched.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:18 -03:00
anhtahaylove
edfcb8be17 fix(test): resolve the WebDAV handler path with fileURLToPath (#13196)
Correct: `URL.pathname` is a URL path, so on Windows it yields `/C:/...` and `path.resolve` produces the doubled `C:\C:\` prefix. `fileURLToPath` is the right decoder and also un-escapes `%20`. All 37 WebDAV tests green here.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: #12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
2026-09-11 13:27:14 -03:00
Nguyen Thanh Dat
af49d4972e fix(stream): accept the buffer size glm.ts has been passing since #12179 (#12925)
Rebased onto the tip and completed, per the maintainer's call to finish the wiring rather than merge the capability alone.

What changed since your version:

The tip had already cleared the TS2554 by deleting the 16th argument, leaving a comment that the highWaterMark stays at the helper default. So the base-red you found is gone, but the 64 KB #12179 asked for was still not applied and your new parameter had no caller. glm.ts now passes it, which is what turns the capability into the fix.

Your test file also hung the runner: every stream createSSEStream builds arms a 10s idle watchdog via setInterval in start, and nothing cancelled them, so node:test waited on a non-empty event loop long after the assertions passed. Cancelling each readable in an after hook runs the cancel handler that clears the timer — the file now reports in about 7 seconds. Worth knowing for future stream tests.

Your five assertions are unchanged and all pass. Reading the writable's desiredSize to measure the queue budget the stream was actually built with, rather than standing in for it, is the detail that makes this testable at all — and the 0-budget case pinning `??` against `||` is the kind of thing that silently rots otherwise.

Thank you also for separating your own red checks from the base's and reporting what you found there. That is how #12919's identical failures got explained instead of chased.
2026-09-10 18:25:31 -03:00
Nguyen Thanh Dat
22473dee50 feat(providers): add EURouter as an OpenAI-compatible gateway (#12985) (#13025)
Rebased onto the release tip after #13024 landed: both PRs extend the same three registration files, so the sibling merge turned this into a conflict. The resolution is additive — both catalog entries kept, both registry imports kept, both base URLs kept — and EURouter stays in AGGREGATOR_PROVIDER_IDS while GreenPT stays out, exactly as each PR argued. 14 provider tests pass on the rebased branch and the file-size gate is green under the annotated rebaseline.

Thank you for re-checking the endpoint live instead of trusting the report, and for the sovereignty caveat. Naming the upstreams from EURouter's own catalog — Claude Sonnet served by AWS Bedrock, 19 models owned by openai — and then writing an apiHint that says routing rather than residency is the kind of care that keeps a provider entry honest. The test asserting the copy contains none of "residency", "stays in the EU", "EU-hosted" or "sovereign" is a good guard against that drifting later.
2026-09-10 18:16:32 -03:00
Nguyen Thanh Dat
2b9e7fb3ec feat(providers): add GreenPT as an OpenAI-compatible provider (#13024)
Merged with a rebaseline commit added on top of your branch: check:file-size freezes the gateways catalog at 1462 lines, so any new entry fails the gate on arrival. The annotation covers this entry and EURouter's (#13025) together, following the route every previous gateway entry took (#11786 seekai, #10987 logfare, #10668 tabitoken, #10531 freebuff, #11631 1min.ai) — the file is declarative data already split into six family files, so splitting it for two entries would break the semantic-families rule.

Validated in a combined worktree with 13 sibling PRs: 132 focused tests pass, typecheck:core clean, file-size green after the rebaseline.

Thank you for stating plainly what you did not verify. "The endpoint exists and is key-gated; catalog, streaming and tool calls not exercised" is worth more than a confident entry that turns out to be guesswork, and the conservative entry that follows from it — empty models, no capability declared, hasFree false with the billing shape spelled out — is exactly right.
2026-09-10 18:14:04 -03:00
Nguyen Thanh Dat
9a56147019 fix(skills): read positionals declared with .addArgument() (#13009)
Approved by the maintainer for the agent-instruction surface it touches: the SKILL.md change is regenerated output from the corrected parser (`resilience set` -> `resilience set <name>`), restoring the required argument the published page had been hiding. No hand-written directive was added.

Boarded with 13 sibling PRs and validated as a set: 132 focused tests pass, typecheck:core clean, changelog integrity and file-size gates green.

Thank you — the table contrasting the declared argument against the published page is what made the second case (an agent told to run `resilience set` with no argument) visible as more than cosmetic.
2026-09-10 18:13:37 -03:00
Nguyen Thanh Dat
751247a143 fix(security): scan both ends of an oversized body, not just the front (#13104)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:13:20 -03:00
Nguyen Thanh Dat
567abb5d68 fix(security): scan the text a tool_result carries (#13101)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:13:16 -03:00
Nguyen Thanh Dat
403a1a697d fix(guardrails): mask PII inside a tool_result's nested content (#12930)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:13:12 -03:00
Nguyen Thanh Dat
f2d5728cfd fix(dashboard): test Responses nodes on /v1/responses, not chat completions (#13070) (#13087)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:13:08 -03:00
Nguyen Thanh Dat
0a314c84de fix(translator): treat contentSchema and unevaluatedItems as schema slots (#13110)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:13:05 -03:00
Nguyen Thanh Dat
1929aa656a fix(validation): accept a null dailyQuotaResetTimezone (#13066) (#13083)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:13:01 -03:00
Nguyen Thanh Dat
4edc3d57d0 fix(azure): match the generation, not one release, for max_completion_tokens (#13007)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:12:57 -03:00
Nguyen Thanh Dat
a6f28210de fix(logs): match the in-memory call-log filter to the SQL one it re-applies (#12896)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:12:54 -03:00
Nguyen Thanh Dat
5df94f8b05 fix(bedrock): resolve context limits for every vendor prefix, not just anthropic (#12921)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:12:49 -03:00
Nguyen Thanh Dat
e1a1290fde fix(compression): keep tool_result blocks first when aging annotates a turn (#12920)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:12:45 -03:00
Nguyen Thanh Dat
ee21e7d2c9 fix(a2a): build the status agent card from the request that asked for it (#12918)
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
2026-09-10 18:12:41 -03:00
Diego Rodrigues de Sa e Souza
fd27ff08c7 chore(deps): drain the Dependabot queue — 10 of 13 alerts (#13213)
* chore(deps): drain the Dependabot queue — 7 of 10 alerts

Lockfile-only bumps; no manifest touched, so nothing changes for consumers.

Root package-lock.json:
  hono      4.13.0 -> 4.13.7  (#215 #216 #217, medium, patched 4.13.5)
  csv-parse 7.0.1  -> 7.0.2   (#213, medium)
  joi       18.2.3 -> 18.2.8  (#211 #212, low, patched 18.2.4/18.2.5)

@omniroute/opencode-plugin:
  toml      4.1.1  -> 4.3.0   (#209, HIGH, patched 4.1.2)

@omniroute/opencode-plugin-v2:
  esbuild   0.28.1 -> 0.28.2  (#210, low) — the direct copy only; see below.

The plugin-v2 diff looks large but is one package: esbuild ships 27 platform
binaries, each carrying version + resolved + integrity.

Three alerts stay open, deliberately:

  #218 extract-zip (HIGH) and #214 adm-zip (medium) have NO published patch.
  Both are dev-scope. Closing them needs an upstream release or a decision to
  replace the dependency — neither belongs in a lockfile bump.

  #210 esbuild is only half-closed. `node_modules/esbuild` is on 0.28.2, but
  `tsup` pins `esbuild: ^0.27.0`, so its nested copy stays at 0.27.7 — inside the
  vulnerable range (>= 0.27.3, < 0.28.1). Updating tsup does not move it (8.5.1
  is already current). Forcing it would take an `overrides` entry pushing a major
  of esbuild inside the bundler, which is exactly the change that breaks a build
  silently, for a LOW dev-only alert. Left for an upstream tsup release.

check:lockfile passes on all three, including the workspace lock/manifest
consistency check. check:tracked-artifacts OK.

* chore(deps): bump js-yaml to 4.3.2 (root + electron)

Two more HIGH alerts arrived after the first sweep:

  #220 js-yaml (root package-lock.json)     >= 4.0.0, < 4.3.2
  #219 js-yaml (electron/package-lock.json) >= 4.0.0, < 4.3.2

The root's own js-yaml was already on 5.4.1; the vulnerable copies were the ones
nested under @yarnpkg/parsers, lockfile-lint, xmlbuilder2 (root) and the direct
dependency in electron. All now 4.3.2. Four version lines, nothing else.

#221 smol-toml (HIGH, <= 1.7.0) is NOT closed here. The root is on 1.8.0; the
vulnerable 1.6.1 sits under @openai/codex-security, which pins it as an EXACT
version rather than a range, so `npm update` cannot move it. Bumping
codex-security itself (0.1.24 -> 0.1.26) does not help — 0.1.26 pins the same
1.6.1 — so that bump was reverted rather than carried along for no benefit.

Closing #221 needs an upstream codex-security release or an `overrides` entry,
the same trade already declined for #210/tsup: forcing a transitive pin from
outside is how a build breaks silently. Note that @openai/codex-security is also
the package carrying the unpatched extract-zip (#218), so one upstream release
would likely clear both.

* chore(deps): override smol-toml to 1.8.0 and raise the js-yaml floor

Closes #221 (smol-toml, HIGH, DoS via malformed TOML, vulnerable <= 1.7.0).

@openai/codex-security pins smol-toml at 1.6.1 as an EXACT version, so no
`npm update` reaches it. This repo already uses `overrides` as its standard tool
for exactly that situation — the block carries 20+ entries, including the
scoped-by-parent form and the `qs`/`fast-uri`/`ip-address` entries that back
earlier security bumps — so a scoped override is the idiomatic fix here, not a
new mechanism:

    "@openai/codex-security": { "smol-toml": "^1.8.0" }

The nested copy deduplicates to the root's existing 1.8.0, which two other
consumers (the root itself and knip) already run, so the version is proven in
this tree. The whole lockfile diff is the 14 lines of the removed 1.6.1 entry.

Also raised the `@yarnpkg/parsers` js-yaml floor from ^4.3.1 to ^4.3.2, so the
override documents the patched version rather than permitting the vulnerable one
it was written against.

Not fixed, and not fixable by version — verified against the npm registry rather
than trusting the advisory metadata:

  #218 extract-zip — latest published IS 2.0.1, the vulnerable version. Dev
       scope, via @openai/codex-security. No release to move to.
  #214 adm-zip — latest published IS 0.6.0, the top of the vulnerable range
       (>= 0.5.9, <= 0.6.0). RUNTIME scope, via onnxruntime-node's ^0.5.16, and
       the repo already overrides adm-zip to ^0.6.0. No release to move to.

Both need an upstream fix or a decision to replace the dependency; neither is a
lockfile change. adm-zip being runtime rather than dev makes it the one worth
tracking.

#210 esbuild stays open too. A flat `overrides: { esbuild: ^0.28.2 }` in
opencode-plugin-v2 does close it — npm then reports 0 vulnerabilities — but it
requires regenerating that lockfile from scratch: 823 lines, 96 packages moved,
for a LOW dev-only alert, and a major esbuild bump inside tsup cannot be
validated here without a real install of that package. Tried, measured,
reverted. Left for an upstream tsup release.

check:lockfile OK on all lockfiles including the workspace consistency check;
check:tracked-artifacts OK; prettier clean.
2026-09-10 13:37:55 -03:00
Diego Rodrigues de Sa e Souza
0549dcfc36 fix(api): scope batch bulk-delete to the calling API key (#13211)
GHSA-wvxc-jp3v-5mg5: `DELETE /api/v1/batches/delete-completed` deleted the
completed batches of EVERY api key on the instance and nulled the contents of
every file those batches referenced. Any ordinary inference key reached it —
including one with `scopes: []` — and no victim key, batch id or file id was
needed.

Two defects stacked in one endpoint:

  - `deleteCompletedBatches()` carried no `api_key_id` predicate. The file
    SELECT, the checkpoint DELETE and the batch DELETE were all instance-wide.
  - The route only checked that SOME key was present (`!scope.apiKeyId` → 401),
    never that the caller owned anything, and called the helper bare.

The helper now takes `apiKeyId` and scopes all three statements to it; the route
passes the caller's key and omits it only for session auth, so the operator's own
dashboard keeps its instance-wide cleanup and an API key clears only its own
completed batches.

None of this is a new pattern. `listBatches(apiKeyId?)` and
`countBatches(apiKeyId?)` in the same module already scope by `api_key_id`, and
`batches/[id]/route.ts` already gates per-record access with `scopeCheck` —
session auth sees everything, a key sees only its own. This one helper was the
one that never got it, which is why the fix reuses the shape instead of
inventing a second convention.

tests/unit/batch-delete-completed-ownership-wvxc.test.ts — 5 tests, 4 red before
the fix, including the two that prove the cross-tenant destruction (another
key's batch survives; another key's file content survives). It also pins the
instance-wide dashboard sweep so the fix cannot be "tightened" into breaking the
operator's own cleanup, and a source guard that the route never calls the helper
bare again.

Reported privately via GHSA-wvxc-jp3v-5mg5.

Closes GHSA-wvxc-jp3v-5mg5
2026-09-10 13:27:10 -03:00
Diego Rodrigues de Sa e Souza
393cfdd660 fix(test): make the ToS heading guard actually require the parentheses (#13228)
CodeQL js/useless-regexp-character-escape (#994-#997) on one line, and it is a
real defect rather than the usual query noise.

The assertion built its pattern in a TEMPLATE literal:

    new RegExp(`\(\s*${String(tos?.actual)}\s*\)`)

JavaScript resolves the escapes before RegExp ever sees the string: `\(` becomes
"(" and `\s` becomes the LETTER "s". The compiled pattern was `(s*16s*)` — a
capture group around optional "s" characters — so it matched any heading merely
CONTAINING the number. The literal parentheses this guard exists to require were
never checked, and it passed on exactly the headings it was written to reject:

    /(s*16s*)/.test("### Caution — clauses worth checking 16")   // true

Doubled the backslashes so they survive the template literal, and routed the
interpolated value through an `escapeRegExp` helper — the count is a number
today, but interpolating an unescaped value into a regex source is the same
class of bug one refactor away.

Added a second test that pins the behaviour rather than the spelling: the
pattern must REJECT a heading carrying the count without parentheses, and accept
it with them (including inner whitespace). Before this fix that test fails.

4/4 green against the real docs/reference/FREE_TIERS.md heading.
2026-09-10 13:27:02 -03:00
Diego Rodrigues de Sa e Souza
d86cf75aef fix(quality): register 4 drifted covering tests in stryker tap.testFiles (#13229)
`check:mutation-test-coverage --strict` has been failing Fast Quality Gates on
every open PR against release/v3.8.51. It grew from 2 missing entries to 5 in
roughly an hour, so it is drifting faster than PRs land.

Four test files cover a mutated module without being listed, so their mutant
kills do not count:

  open-sse/services/accountFallback.ts      <- openai-compatible-per-upstream-402-health
  src/sse/services/auth.ts                  <- openai-compatible-per-upstream-402-health
                                            <- quota-window-label
  src/shared/utils/circuitBreaker.ts        <- combo/execute-target-gates
  open-sse/services/combo/comboStructure.ts <- combo-pin-implicit-allowlist

Registration only — no test or module is touched, and no gate is weakened; the
listing is what makes those kills count in the first place.

Inserted in place, never through a JSON round-trip: re-serializing this file
reorders the ~10 curated entries that are already out of alphabetical order
(learned the hard way in #11438).

check:mutation-test-coverage now reports no drift. check:tracked-artifacts OK,
prettier clean.

Worth noting for whoever adds the next test: this gate fires whenever a NEW test
happens to cover one of the 31 mutated modules, which is easy to do without
realising. Registering it in the same commit is cheaper than a CI round-trip.
2026-09-10 13:26:53 -03:00
172 changed files with 5561 additions and 834 deletions

View File

@@ -124,21 +124,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Host port for the compose Redis sidecar. Default: 6379.
# REDIS_PORT=6379
# Host interface docker-compose publishes the app's own ports (dashboard,
# API, live-WS) on for the base/web/cli/host profiles and docker-compose.prod.yml.
# Default: 127.0.0.1 (loopback only). Combined with REQUIRE_API_KEY=false
# (the default below), an unqualified publish spec would expose the anonymous
# /v1 LLM proxy to your whole LAN/WAN. Only set this to 0.0.0.0 once you've
# confirmed REQUIRE_API_KEY=true, or that a reverse proxy in front of this
# instance already enforces its own authentication. (#12568)
# APP_BIND_HOST=127.0.0.1
# Host interface docker-compose publishes the Qdrant memory sidecar on.
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
# QDRANT_BIND_HOST=127.0.0.1
# Host interface docker-compose publishes the Bifrost router sidecar on.
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
# BIFROST_BIND_HOST=127.0.0.1
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -388,8 +373,6 @@ AUTH_COOKIE_SECURE=false
# Require an API key for all /v1/* proxy endpoints.
# Used by: API middleware — rejects unauthenticated requests to the proxy API.
# Default: false | Set true for multi-user/public deployments.
# Leaving this false is only safe when the app is reachable on loopback only
# (see APP_BIND_HOST above) or sits behind a reverse proxy doing its own auth.
REQUIRE_API_KEY=false
# Allow revealing full API key values in the Dashboard UI.
@@ -2094,13 +2077,6 @@ APP_LOG_TO_FILE=true
# Management key for an externally managed instance. Embedded instances use
# OmniRoute's encrypted service key.
# CLIPROXYAPI_MANAGEMENT_KEY=
# Host interface docker-compose publishes the cliproxyapi sidecar on (the
# --profile cliproxyapi Docker service, port 8317). Default: 127.0.0.1
# (loopback only) — its data volume holds provider OAuth/API credentials, and
# the pinned image has no env-based data-plane api-keys override (only a
# mounted config.yaml), so an unqualified publish spec would put a
# credential-bearing service on your whole LAN. (#12578)
# CLIPROXY_BIND_HOST=127.0.0.1
# ── Mux embedded service ──
# Override the port where the embedded Mux (coder/mux) agent-orchestration
@@ -3094,6 +3070,11 @@ QUOTA_STORE_DRIVER=sqlite
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
# TELEGRAM_BOT_TOKEN=
# Shared secret registered with setWebhook and echoed back by Telegram as the
# X-Telegram-Bot-Api-Secret-Token header. REQUIRED for the webhook path: without
# it the webhook is rejected with 503, because an unauthenticated update lets any
# caller mint API keys and spend upstream quota. The Mini App path does not use it.
# TELEGRAM_WEBHOOK_SECRET=
# TELEGRAM_DEFAULT_MODEL=auto/chat
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000

View File

@@ -22,7 +22,7 @@
"node": ">=22.22.3"
},
"peerDependencies": {
"@opencode-ai/plugin": "*"
"@opencode-ai/plugin": ">=1.18.29 <2"
}
},
"node_modules/@ai-sdk/provider": {
@@ -39,9 +39,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -56,9 +56,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -73,9 +73,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -90,9 +90,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -107,9 +107,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -124,9 +124,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -141,9 +141,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -158,9 +158,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -175,9 +175,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -192,9 +192,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -226,9 +226,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -243,9 +243,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -260,9 +260,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -277,9 +277,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -294,9 +294,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -311,7 +311,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -326,9 +328,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -343,9 +345,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -360,9 +362,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -377,9 +379,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -394,9 +396,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -411,9 +413,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -428,9 +430,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -445,9 +447,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -462,9 +464,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -1180,7 +1182,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1191,32 +1195,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fast-check": {

View File

@@ -10,6 +10,7 @@ import type {
OmniRouteRawCombo,
OmniRouteRawModelEntry,
} from "./shared/index.js";
import { isHttpUrl } from "./shared/index.js";
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
@@ -34,8 +35,9 @@ export const SNAPSHOT_FORMAT_VERSION = 2 as const;
/**
* A raw snapshot entry is stale when it cannot be mapped to a publishable
* model: no string `id` (unroutable) or a pre-mapped `api` block without a
* valid `npm` package (the runner would reject it as `Unsupported package`).
* model: no string `id` (unroutable), or a pre-mapped `api` block missing a
* valid `npm` package (the runner would reject it as `Unsupported package`)
* or a usable `url` (the host would reach the AI SDK with no baseURL).
* Plain `/v1/models` entries carry no `api` block -- it is synthesized at
* publish time -- so only a present-but-invalid block drops the entry.
*/
@@ -47,7 +49,11 @@ export function isStaleSnapshotModel(entry: unknown): boolean {
if (api === undefined) return false;
if (!api || typeof api !== "object") return true;
const npm = (api as { npm?: unknown }).npm;
return typeof npm !== "string" || npm.length === 0;
if (typeof npm !== "string" || npm.length === 0) return true;
// Same requirement as `npm`, and the same predicate the options schema
// applies to `baseURL`: a pre-mapped block without a callable `url` publishes
// a model the host cannot route -- see `legacyApiToInfoApi`.
return !isHttpUrl((api as { url?: unknown }).url);
}
interface DiskSnapshotV2 {
@@ -145,7 +151,7 @@ export async function readDiskSnapshot(
(entry) => !isStaleSnapshotModel(entry)
);
if (stale > 0) {
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`);
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries with an unusable api block`);
}
if (models.length === 0) return undefined;
return {

View File

@@ -3,6 +3,7 @@ import { type HostContract, detectHostContract, emitsLegacyFields } from "./comp
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import {
isHttpUrl,
type ApiFormatV2,
type LogLevel,
type Logger,
@@ -142,6 +143,15 @@ export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
);
}
// The host reads `api.url` in `prepareOptions` and never falls back to the
// provider's own, so a model published without one reaches the AI SDK with no
// baseURL and fails at call time with a bare `Invalid URL` — no request on the
// wire, nothing in the gateway logs, no model named.
if (!isHttpUrl(api.url)) {
throw new Error(
"[omniroute-v2] refusing to publish a model whose api block carries no http(s) url"
);
}
return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
}

View File

@@ -1,5 +1,7 @@
import { z } from "zod";
import { isHttpUrl } from "./shared/models-map.js";
const apiFormatSchema = z
.object({
allowAnthropic: z.boolean().optional(),
@@ -28,7 +30,10 @@ const pluginOptionsSchema = z
.regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'")
.refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment")
.default("omniroute"),
baseURL: z.string().url(),
baseURL: z
.string()
.trim()
.refine(isHttpUrl, "baseURL must be an http(s) URL, for example http://localhost:20128"),
apiKey: z.string().optional(),
displayName: z.string().optional(),
managementReadToken: z.string().optional(),

View File

@@ -111,6 +111,22 @@ function trimTrailingSlashes(value: string): string {
* (it appends `/v1/messages` automatically), so callers should branch on
* format first.
*/
/**
* A url the AI SDK can actually call. `new URL()` alone is not enough: it
* parses `localhost:20128` as the scheme `localhost:` and `ftp://host` as ftp,
* both of which reach `fetch` and fail there. Mirrors the `isHttpUrl` guard the
* settings schema applies to `headroomUrl`.
*/
export function isHttpUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;

View File

@@ -29,6 +29,38 @@ describe("parsePluginOptions", () => {
it("requires baseURL", () => {
assert.throws(() => parsePluginOptions({}), /baseURL/);
});
it("rejects a baseURL that is not an http(s) URL", () => {
// `new URL()` reads "localhost:20128" as the scheme "localhost:" followed
// by a path, so a gateway address typed without "http://" parses. Every
// model would then be published with "localhost:20128/v1" as its api url
// and every call would fail in the client on an unknown scheme, with no
// request on the wire and nothing in the gateway logs.
for (const baseURL of [
"localhost:20128",
"localhost:20128/v1",
"ftp://gw.example.com/v1",
"gw.example.com/v1",
]) {
assert.throws(
() => parsePluginOptions({ baseURL }),
/baseURL must be an http\(s\) URL/,
`expected ${baseURL} to be rejected`
);
}
});
it("accepts http and https baseURLs, with or without a port or path", () => {
for (const baseURL of [
"http://localhost:20128/v1",
"http://localhost:20128",
"https://gw.example.com/v1",
"https://gw.example.com/omniroute/v1",
]) {
assert.equal(parsePluginOptions({ baseURL }).baseURL, baseURL);
// Padding a copied address is trimmed rather than rejected, matching the
// treatment `headroomUrl` already gets in the settings schema.
assert.equal(parsePluginOptions({ baseURL: ` ${baseURL} ` }).baseURL, baseURL);
}
});
it("rejects unknown top-level keys (strict)", () => {
assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 }));
});

View File

@@ -5,7 +5,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import plugin from "../src/index.js";
import { diskSnapshotPath, snapshotIdentityFingerprint } from "../src/cache.js";
import {
diskSnapshotPath,
isStaleSnapshotModel,
snapshotIdentityFingerprint,
} from "../src/cache.js";
import { legacyApiToInfoApi } from "../src/catalog.js";
function isolateDisk(): { dir: string; restore: () => void } {
@@ -97,7 +101,7 @@ function downFetch(): typeof fetch {
const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix");
describe("plugin-v2 snapshot stale-entry filter", () => {
it("snapshot with 2 entries without api block + 1 valid: only the valid one is published + warn emitted", async () => {
it("snapshot with 3 unusable pre-mapped entries + 1 valid: only the valid one is published + warn emitted", async () => {
const disk = isolateDisk();
const providerId = "snapfix-mixed";
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
@@ -106,11 +110,15 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
JSON.stringify({
v: 2,
identityFingerprint: fingerprint,
// Two pre-mapped entries with a broken api block (missing npm) plus
// one plain raw entry (no api block: synthesized at publish time).
// Three pre-mapped entries with an unusable api block missing npm,
// empty npm, and a well-formed npm with no url (the shape a snapshot
// written by an older build carries, and the one that reaches the host
// as a bare `Invalid URL`) — plus one plain raw entry, which has no api
// block at all and gets one synthesized at publish time.
models: [
{ id: "stale-a", api: {} },
{ id: "stale-b", api: { npm: "" } },
{ id: "stale-c", api: { id: "openai-compatible", npm: "@ai-sdk/openai-compatible" } },
{ id: "good-1", context_length: 128000 },
],
combos: [],
@@ -137,7 +145,7 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
);
});
assert.ok(
warns.some((w) => w.includes("dropping 2 stale snapshot entries without api block")),
warns.some((w) => w.includes("dropping 3 stale snapshot entries with an unusable api block")),
`expected stale-drop warn, got: ${JSON.stringify(warns)}`
);
} finally {
@@ -216,4 +224,56 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
// Sanity: sha256 helper used above matches the plugin identity scheme.
assert.equal(createHash("sha256").update("x").digest("hex").length, 64);
});
it("legacyApiToInfoApi throws unless api.url is an http(s) url", () => {
const npm = "@ai-sdk/openai-compatible";
for (const api of [
{ id: "openai-compatible", npm },
{ id: "openai-compatible", npm, url: "" },
{ id: "openai-compatible", npm, url: " " },
// Non-empty but uncallable: the AI SDK reaches `fetch` and fails there.
{ id: "openai-compatible", npm, url: "/v1" },
{ id: "openai-compatible", npm, url: "gw.example.com/v1" },
{ id: "openai-compatible", npm, url: "ftp://gw.example.com/v1" },
]) {
assert.throws(
() => legacyApiToInfoApi(api as unknown as { id: string; npm: string; url: string }),
/api block carries no http\(s\) url/,
`expected a publish-time refusal for ${JSON.stringify(api)}`
);
}
// A complete block still publishes unchanged.
assert.deepEqual(
legacyApiToInfoApi({
id: "openai-compatible",
npm: "@ai-sdk/openai-compatible",
url: "https://gw.example.com/v1",
}),
{
id: "openai-compatible",
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://gw.example.com/v1",
}
);
});
it("isStaleSnapshotModel drops a pre-mapped entry whose api.url is unusable", () => {
const npm = "@ai-sdk/openai-compatible";
// Present-but-unusable url: stale, for the same reason a missing npm is.
for (const url of [undefined, "", " ", "/v1", "gw.example.com/v1", "ftp://gw/v1"]) {
assert.equal(
isStaleSnapshotModel({ id: "a/b", api: { id: "x", npm, ...(url === undefined ? {} : { url }) } }),
true,
`expected ${JSON.stringify(url)} to be treated as stale`
);
}
// Complete block: publishable.
assert.equal(
isStaleSnapshotModel({ id: "a/b", api: { id: "x", npm, url: "https://gw/v1" } }),
false
);
// No api block at all stays publishable: it is synthesized at publish time.
assert.equal(isStaleSnapshotModel({ id: "a/b" }), false);
});
});

View File

@@ -1745,9 +1745,9 @@
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz",
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -220,7 +220,11 @@ const optionsSchema = z
* to 60000. Default when unset: 300000.
*/
autoSyncIntervalMs: z.number().int().nonnegative().optional(),
baseURL: z.string().url().optional(),
baseURL: z
.string()
.trim()
.refine(isHttpUrl, "baseURL must be an http(s) URL, for example http://localhost:20128")
.optional(),
managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(),
})
@@ -482,6 +486,22 @@ export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro",
* (it appends `/v1/messages` automatically), so callers should branch on
* format first.
*/
/**
* A url the AI SDK can actually call. `new URL()` alone is not enough: it
* parses `localhost:20128` as the scheme `localhost:` and `ftp://host` as ftp,
* both of which reach `fetch` and fail there. Mirrors the `isHttpUrl` guard the
* settings schema applies to `headroomUrl`.
*/
export function isHttpUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;

View File

@@ -59,6 +59,26 @@ test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () =
assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i);
});
test("parseOmniRoutePluginOptions: baseURL without an http(s) scheme → throws", () => {
// `new URL()` reads "localhost:20128" as the scheme "localhost:" followed by
// a path, so the address parses and the models are published with an api url
// no client can call.
for (const baseURL of ["localhost:20128", "localhost:20128/v1", "ftp://or.example.com", "or.example.com"]) {
assert.throws(
() => parseOmniRoutePluginOptions({ baseURL }),
/baseURL must be an http\(s\) URL/,
`expected ${baseURL} to be rejected`
);
}
});
test("parseOmniRoutePluginOptions: http and https baseURLs are accepted, padding trimmed", () => {
for (const baseURL of ["http://localhost:20128", "https://or.example.com/v1"]) {
assert.equal(parseOmniRoutePluginOptions({ baseURL }).baseURL, baseURL);
assert.equal(parseOmniRoutePluginOptions({ baseURL: ` ${baseURL} ` }).baseURL, baseURL);
}
});
test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => {
assert.throws(
() =>

View File

@@ -0,0 +1 @@
- **feat(providers):** Added EURouter as an OpenAI-compatible API-key gateway (`https://api.eurouter.ai/v1`), with live model discovery via `passthroughModels`. Its copy states that models are served by third-party upstreams listed per model, so an EU-based router is not read as EU data residency for inference.

View File

@@ -0,0 +1 @@
- **feat(providers):** Added GreenPT as an OpenAI-compatible API-key provider (`https://api.greenpt.ai/v1`), with live model discovery via `passthroughModels`. No free-inference badge: the published docs describe a free API subscription billed per token, not a free tier.

View File

@@ -0,0 +1 @@
- **fix(dashboard):** model health tests for a provider node set to the Responses API now call `/v1/responses` with a Responses-shaped body instead of `/v1/chat/completions` — those models were reported as `Provider returned HTTP 200 but no text content` even though the same model answered normally through `/v1/responses` ([#13070](https://github.com/diegosouzapw/OmniRoute/issues/13070))

View File

@@ -1 +0,0 @@
- fix(docker): default docker-compose app ports (dashboard/API/live-WS) to loopback instead of `0.0.0.0`, closing the anonymous `/v1` LAN/WAN exposure gap left open by `REQUIRE_API_KEY=false` (#12568)

View File

@@ -1 +0,0 @@
- fix(docker): scope the cliproxyapi/qdrant/bifrost sidecars to loopback by default and forward `CLIPROXYAPI_MANAGEMENT_KEY` into the cliproxyapi container so its management API is not left both unauthenticated and LAN-published (#12578)

View File

@@ -0,0 +1 @@
- **fix(logs):** the Logs grid's in-memory filter pass no longer discards rows the SQL query already matched — selecting an API key from the dropdown (which sends the key's id) returns its calls again, the Combo tab shows every combo instead of only those whose name contains a "1", and the model filter and search cover the same columns as the query ([#12896](https://github.com/diegosouzapw/OmniRoute/pull/12896)) — fixes [#12873](https://github.com/diegosouzapw/OmniRoute/issues/12873)

View File

@@ -0,0 +1 @@
- **fix(a2a):** `/api/a2a/status` now builds the agent card from the request that asked for it, so a gateway reached at a non-localhost host no longer advertises `http://localhost:20128` as its A2A URL ([#12918](https://github.com/diegosouzapw/OmniRoute/pull/12918)).

View File

@@ -0,0 +1 @@
- **fix(compression):** progressive aging now appends its `[COMPRESSED:aging:…]` annotation after a turn's `tool_result` blocks instead of in front of them, so Anthropic no longer rejects aged conversations with "`tool_use` ids were found without `tool_result` blocks immediately after" ([#12920](https://github.com/diegosouzapw/OmniRoute/pull/12920)).

View File

@@ -0,0 +1 @@
- **fix(bedrock):** model import now resolves context limits for every vendor prefix instead of only `anthropic.*`, so `global.openai.gpt-5.6-*` no longer imports with a null `inputTokenLimit` and gets rejected pre-flight at the 200k default ([#12921](https://github.com/diegosouzapw/OmniRoute/pull/12921)).

View File

@@ -0,0 +1 @@
- **fix(stream):** the 64 KB stream buffer GLM asks for is honoured instead of dropped, and the type error it caused no longer fails the API Route Typecheck gate on every open PR ([#12925](https://github.com/diegosouzapw/OmniRoute/pull/12925))

View File

@@ -0,0 +1 @@
- **fix(guardrails):** mask PII inside a `tool_result`'s nested content array, which the masker walked past while redacting its sibling block ([#12930](https://github.com/diegosouzapw/OmniRoute/pull/12930))

View File

@@ -0,0 +1 @@
- **fix(sse):** transient opencode upstream failures rotate to the next account proxy instead of failing, so one flapping egress no longer aborts the whole chain ([#12975](https://github.com/diegosouzapw/OmniRoute/pull/12975)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(azure):** Deployments from GPT-6 onward now send `max_completion_tokens` instead of `max_tokens`, which Azure rejects with HTTP 400. The rule matched a literal `gpt-5`, so each new generation arrived broken; it now matches the generation range, while `gpt-35-turbo` still keeps `max_tokens`.

View File

@@ -0,0 +1 @@
- **fix(acp):** bound the ACP session output buffers — `stdoutBuffer` and `stderrBuffer` now cap at 1 MiB keeping the most recent output behind a visible `[...output truncated...]` marker, and `stderrBuffer` is reset per prompt instead of accumulating for the lifetime of the session.

View File

@@ -0,0 +1 @@
- **fix(acp):** release the `stdout`/`exit` listeners and the idle timer that a `sendPrompt` timeout used to leave attached to the `acpManager` singleton, and drop sessions that exited on their own from the session map instead of keeping them forever.

View File

@@ -0,0 +1 @@
- **fix(security):** the prompt-injection and PII scanners now read the text a `tool_result` block carries on `content` (string or nested block list), in messages and in system blocks, so tool output is judged by the same rules as user text ([#13101](https://github.com/diegosouzapw/OmniRoute/pull/13101))

View File

@@ -0,0 +1 @@
- **fix(gamification):** close the badge notification SSE stream when the request signal is already aborted before the stream starts — a client that disconnects while the route is still awaiting auth used to leave both the 2s unlock poll and the 15s heartbeat running for the lifetime of the process.

View File

@@ -0,0 +1 @@
- **fix(security):** the prompt-injection scan now spends its 16 KB budget on both ends of the request instead of the first 16 KB only, so `system`, `instructions`, `query`, `documents` and the newest turns are no longer hidden behind one long message ([#13104](https://github.com/diegosouzapw/OmniRoute/pull/13104))

View File

@@ -0,0 +1 @@
- **fix(db):** release the `beforeExit`/`SIGINT`/`SIGTERM` handlers when a `node:sqlite` adapter closes, so a closed adapter and its database handle are no longer pinned to `process` for the lifetime of the run — the same treatment #7494 gave the sql.js adapter.

View File

@@ -0,0 +1 @@
- **fix(translator):** `contentSchema` and `unevaluatedItems` are now treated as subschema positions by the tool-schema sanitizer, so a truncation placeholder in either is replaced with a permissive schema instead of being forwarded as a string ([#13110](https://github.com/diegosouzapw/OmniRoute/pull/13110))

View File

@@ -0,0 +1 @@
- **fix(cli-helper):** clear the `createLogStream` timeout on the abort path — `stop()` aborts the in-flight fetch and returned through the `signal.aborted` branch, which skipped `clearTimeout` and left an armed timer per stopped stream. The stream reader is now also cancelled when the read loop exits early.

View File

@@ -0,0 +1 @@
- **fix(combo):** parse numeric-epoch `rate_limited_until` in the combo cooldown read path ([#13141](https://github.com/diegosouzapw/OmniRoute/pull/13141)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(opencode):** both OpenCode plugins now reject a gateway address typed without `http://` at configuration time, instead of publishing every model with an api url no client can call, and the v2 plugin no longer publishes a model card whose api url is blank or relative ([#13142](https://github.com/diegosouzapw/OmniRoute/pull/13142)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(providers):** lock opencode model on upstream 400 model-unavailable ([#13146](https://github.com/diegosouzapw/OmniRoute/pull/13146)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(logging):** keep the provider exchange rather than the raw client bodies when a call log exceeds its size budget, and show that recovered payload in the request-detail panel instead of replacing it with the stored response body ([#13147](https://github.com/diegosouzapw/OmniRoute/pull/13147)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(traffic-inspector):** the WebSocket route no longer leaks a traffic-buffer subscriber and a 30s ping timer when the client socket is already closed at handler time — listeners are attached before any resource is acquired, a destroyed socket bails out early, and the ping interval stops on a dead socket where `write()` never throws ([#13155](https://github.com/diegosouzapw/OmniRoute/pull/13155))

View File

@@ -0,0 +1 @@
- **fix(telegram):** bound the per-user API key cache in the Telegram chat proxy so a burst of distinct chat ids can no longer grow the process heap without limit ([#13165](https://github.com/diegosouzapw/OmniRoute/issues/13165))

View File

@@ -0,0 +1 @@
- **fix(stream):** cancel the upstream response body when the JSON-to-SSE sniff unwinds on a body timeout, so a stalled upstream no longer pins the connection ([#13169](https://github.com/diegosouzapw/OmniRoute/issues/13169))

View File

@@ -0,0 +1 @@
- **fix(telegram):** authenticate webhook deliveries with Telegram's `secret_token` so an unauthenticated caller can no longer mint API keys or spend upstream quota ([#13172](https://github.com/diegosouzapw/OmniRoute/issues/13172))

View File

@@ -0,0 +1 @@
- **fix(auth):** a dashboard session now requires the `authenticated: true` claim that login, OIDC and the session refresh already emit — a JWT merely signed with `JWT_SECRET` (for example the Cursor CLI passthrough token, which any API-key holder can obtain) no longer verifies as the `auth_token` cookie on any route, the WebSocket handshake or the live server; existing sessions keep working ([#13298](https://github.com/diegosouzapw/OmniRoute/issues/13298))

View File

@@ -0,0 +1 @@
- **fix(skills):** The CLI registry parser now reads positionals declared with `.addArgument()`, not only those written inline in `.command()`. `tunnel create [type]` was being published as `tunnel create`, so the agent-skills sync gate reported drift on every branch and regenerating would have deleted the argument.

View File

@@ -0,0 +1 @@
- fix(compression): terminate idle worker threads on eviction so long-running instances stop leaking OS threads and MessagePorts

View File

@@ -0,0 +1 @@
- fix(compression): spawn the LLMLingua worker with a file URL object so compression actually runs instead of silently failing open on Node

View File

@@ -0,0 +1 @@
- **fix(test):** run the local `test` and `test:unit` scripts at concurrency 4 so a full-suite run no longer exhausts the machine's commit charge and kills unrelated processes

View File

@@ -0,0 +1 @@
- fix(plugins): stop leaking an exit listener per plugin hook timeout, which triggered MaxListenersExceededWarning on plugins that ignore SIGTERM

View File

@@ -0,0 +1 @@
- **fix(validation):** Provider node edits no longer fail with a generic "Invalid request" when the optional daily-quota reset fields are left blank. The dashboard sends `dailyQuotaResetTimezone` and `dailyQuotaResetHour` as `null`, and only the hour accepted it. ([#13066](https://github.com/diegosouzapw/OmniRoute/issues/13066))

View File

@@ -0,0 +1 @@
- **fix(test):** resolve the WebDAV handler path with `fileURLToPath` so the suite's 37 WebDAV tests run on Windows instead of failing with a doubled `C:\C:\` drive prefix

View File

@@ -1,4 +1,7 @@
{
"_rebaseline_2026_09_10_12975_rotation_correlation_id": "PR #12975 own growth: open-sse/executors/base.ts 1751->1753 (+2) and open-sse/handlers/chatCore.ts 6021->6024 (+3). The opencode rotation lines carry the request correlationId: one optional ExecuteInput field and one correlationId argument at each of the three executor.execute call sites in handleChatCore. Irreducible plumbing at existing call sites; the rotation logic itself lives in open-sse/executors/opencode.ts and the new leaf predicates (under cap). Covered by tests/unit/opencode-transient-rotation.test.ts and tests/unit/chat-correlation-id-exhaustion.test.ts.",
"_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode": "/merge-batch 2026-09-11 (v3.8.51), PRs #13141, #13146 and #12975 by maxmad64bis. src/sse/services/auth.ts 3450->3488 (+38): #13146 adds the narrow ruleScope===model branch to markAccountUnavailable (gated on status 400; every other status keeps its path) plus the HONORS_RULE_LOCK_SCOPE_PROVIDERS opencode entry, taking it to 3464; #12975 then adds buildExhaustionOptions so the exhaustion log lines carry the request correlationId (+24). open-sse/services/accountFallback.ts 2467->2468 (+1): #13141 routes hasFutureRateLimitUntil through the tolerant epoch normalizer; #13146 is net zero there (+16/-16). open-sse/executors/base.ts 1751->1753 (+2): #12975 adds the optional ExecuteInput.correlationId field with its doc comment. src/sse/handlers/chat.ts is NOT rebaselined: #12975 threads correlationId through the three executor call sites (+2) but the file lands at 2452, still under its existing 2458 freeze. open-sse/utils/stream.ts is deliberately NOT rebaselined either: it is already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). No new branching beyond the two guarded branches named above. Covered by tests/unit/combo-predicates-epoch-cooldown.test.ts, opencode-400-model-unavailable.test.ts, agentrouter-error-rules.test.ts, opencode-transient-rotation.test.ts and chat-correlation-id-exhaustion.test.ts.",
"_rebaseline_2026_09_10_mergebatch_v3851_greenpt_eurouter": "/merge-batch 2026-09-10 (v3.8.51), PRs #13024 (GreenPT, closes #12986) and #13025 (EURouter, closes #12985) by ntdatt812: src/shared/constants/providers/apikey/gateways.ts 1462->1502 (+40 = two APIKEY_PROVIDERS_GATEWAYS catalog entries, declarative data only: id/alias/name/icon/color/website plus the hasFree=false rationale comments and the apiHint copy each PR verified). No logic and no new branching. Same god-file no-split rationale as every prior gateways.ts rebaseline (#11786 seekai, #10987 logfare, #10668 tabitoken, #10531 freebuff, #11631 1min.ai): the file header says it is pure data merged by apikey/index.ts via spread, and it is already split into 6 family files under apikey/, so splitting a catalog for two entries would violate the semantic-families rule rather than help. Both entries are deliberately conservative (models: [] with passthroughModels, no tool/vision capability declared, hasFree false), so the growth is the entry itself, not claims. EURouter is in AGGREGATOR_PROVIDER_IDS because it routes to third-party upstreams; GreenPT is not because it serves its own inference. Covered by tests/unit/greenpt-provider.test.ts and tests/unit/eurouter-provider.test.ts.",
"_rebaseline_2026_09_10_12828_translate_usage_chunk": "PR #12828 own growth: open-sse/utils/stream.ts 3072->3080 (+8). Translate-mode streams now send the estimated usage as the canonical trailing usage-only chunk before [DONE] when the upstream stays silent (parity with the #12151 passthrough flush), with a latch so a finish chunk that already carried the estimate is not doubled. The chunk builder is shared with the passthrough flush in open-sse/utils/usageOnlyChunk.ts (under cap); what remains is the flush-site wiring. Covered by tests/unit/stream-translate-usage-trailing.test.ts.",
"_rebaseline_2026_09_10_12715_queue_budget": "PR #12715 own growth: open-sse/handlers/chatCore.ts 6021->6036 (+15). Hierarchical admission now resolves the per-connection queue budget before the gates and hands withRateLimit the remaining budget, the correlation id and the executor timeout context, so gate wait, provider slot and Bottleneck queue share one bound instead of stacking. Error shaping lives in open-sse/handlers/chatCore/queueBudget.ts (under cap); what remains is irreducible call-site wiring. Covered by tests/unit/rate-limit-remaining-budget.test.ts, rate-limit-manager-queue-bound.test.ts and chatcore-hierarchical-admission.test.ts.",
"_rebaseline_2026_09_06_runtime_quotagroup_nodemap": "Own growth: src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx 1201->1222 (+21, check-file-size split-newline). QuotaGroup is a module-level sibling and was reading nodeMap from RuntimePageClient's closure; that identifier is not in scope, so a quota monitor with status error/exhausted/alerting throws ReferenceError. Fix threads nodeMap as a prop (3 call sites + parameter + ProviderNodeEntry import). Prettier wraps the long import and the three QuotaGroup JSX tags. Covered by tests/unit/ui/runtime-page-client.test.tsx (empty monitors stay green; error+exhausted fixtures mount QuotaGroup).",
@@ -421,7 +424,7 @@
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1751,
"open-sse/executors/base.ts": 1753,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1505,
"open-sse/executors/cursor.ts": 1759,
@@ -432,7 +435,7 @@
"open-sse/handlers/search.ts": 1789,
"open-sse/mcp-server/schemas/tools.ts": 1621,
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2467,
"open-sse/services/accountFallback.ts": 2468,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
"open-sse/services/combo.ts": 4080,
"open-sse/services/combo/executeTargetAttempt.ts": 1205,
@@ -465,10 +468,10 @@
"src/lib/tailscaleTunnel.ts": 1208,
"src/lib/tokenHealthCheck.ts": 1218,
"src/shared/components/RequestLoggerV2.tsx": 1718,
"src/shared/constants/providers/apikey/gateways.ts": 1462,
"src/shared/constants/providers/apikey/gateways.ts": 1502,
"src/shared/services/cliRuntime.ts": 1296,
"src/sse/handlers/chat.ts": 2458,
"src/sse/services/auth.ts": 3450,
"src/sse/services/auth.ts": 3488,
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656,
"open-sse/services/autoCombo/virtualFactory.ts": 1219,

View File

@@ -63,22 +63,17 @@ services:
- DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
- API_PORT=${API_PORT:-20129}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}}
- API_HOST=${API_HOST:-127.0.0.1}
# HOSTNAME intentionally not hardcoded to 0.0.0.0 (#12568) — let the
# app's own loopback-first default apply unless the operator sets it.
- API_HOST=${API_HOST:-0.0.0.0}
- HOSTNAME=0.0.0.0
- DATA_DIR=/app/data
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
ports:
# Loopback-only by default (#12568) — see docker-compose.yml's
# APP_BIND_HOST comment for the rationale. Override for a LAN/WAN prod
# deployment only once REQUIRE_API_KEY=true or a reverse proxy in front
# of this instance is confirmed to enforce its own auth.
- "${APP_BIND_HOST:-127.0.0.1}:${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}"
- "${APP_BIND_HOST:-127.0.0.1}:${PROD_API_PORT:-20131}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
- "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}"
- "${PROD_API_PORT:-20131}:${API_PORT:-20129}"
- "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
volumes:
- omniroute-prod-data:/app/data
healthcheck:

View File

@@ -37,9 +37,9 @@ x-common: &common
- PORT=${PORT:-20128}
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-127.0.0.1}
- API_HOST=${API_HOST:-0.0.0.0}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- NODE_OPTIONS=--max-old-space-size=2048
@@ -99,14 +99,9 @@ services:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:base
ports:
# Loopback-only by default (#12568): with REQUIRE_API_KEY=false shipping
# as the .env.example default, an unqualified publish spec here binds
# 0.0.0.0 and exposes the anonymous /v1 LLM proxy on every LAN/WAN
# interface. Set APP_BIND_HOST=0.0.0.0 only once you've confirmed
# REQUIRE_API_KEY=true or an upstream reverse proxy enforces its own auth.
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
profiles:
- base
@@ -131,17 +126,17 @@ services:
- PORT=${PORT:-20128}
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-127.0.0.1}
- API_HOST=${API_HOST:-0.0.0.0}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
ports:
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
profiles:
- web
@@ -170,9 +165,9 @@ services:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:cli
ports:
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
volumes:
- ./data:/app/data
# SECURITY: mounting the host Docker socket gives this container full
@@ -199,17 +194,17 @@ services:
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
image: omniroute:base
ports:
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
- "${API_PORT:-20129}:${API_PORT:-20129}"
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
environment:
- DATA_DIR=/app/data
- PORT=${PORT:-20128}
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
- API_PORT=${API_PORT:-20129}
- API_HOST=${API_HOST:-127.0.0.1}
- API_HOST=${API_HOST:-0.0.0.0}
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- CLI_MODE=host
- CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin
@@ -248,8 +243,8 @@ services:
container_name: omniroute-qdrant
restart: unless-stopped
ports:
- "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_PORT:-6333}:6333"
- "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_GRPC_PORT:-6334}:6334"
- "${QDRANT_PORT:-6333}:6333"
- "${QDRANT_GRPC_PORT:-6334}:6334"
volumes:
- qdrant-data:/qdrant/storage
environment:
@@ -276,7 +271,7 @@ services:
container_name: omniroute-bifrost
restart: unless-stopped
ports:
- "${BIFROST_BIND_HOST:-127.0.0.1}:${BIFROST_PORT:-8080}:8080"
- "${BIFROST_PORT:-8080}:8080"
volumes:
- bifrost-data:/data
environment:
@@ -299,22 +294,12 @@ services:
image: docker.io/eceasy/cli-proxy-api:v6.9.7
restart: unless-stopped
ports:
# Loopback-only by default: this sidecar's data volume
# (cliproxiapi-data:/root/.cli-proxy-api) holds provider OAuth/API
# credentials, and the pinned image only reads api-keys from a mounted
# config.yaml (not env vars), so an unqualified "8317:8317" publish spec
# would put a credential-bearing service with no compose-configured
# data-plane auth on every LAN interface. Same reasoning as Redis above.
- "${CLIPROXY_BIND_HOST:-127.0.0.1}:${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
volumes:
- cliproxyapi-data:/root/.cli-proxy-api
environment:
- PORT=${CLIPROXYAPI_PORT:-8317}
- HOST=0.0.0.0
# Forwards to the one auth-related env var the pinned binary actually
# reads (MANAGEMENT_PASSWORD) — secures the management API only; the
# data-plane completions endpoints have no env-based override upstream.
- MANAGEMENT_PASSWORD=${CLIPROXYAPI_MANAGEMENT_KEY:-}
healthcheck:
test:
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]

View File

@@ -35,6 +35,14 @@ For dashboard pages and admin operations.
Cookie: auth_token=<JWT signed with JWT_SECRET>
```
A cookie is a session only when the JWT verifies **and** carries `authenticated: true`
(`src/shared/utils/dashboardSessionToken.ts``verifyDashboardSessionToken`). Every
consumer of the cookie (route guard, authz pipeline refresh, WebSocket handshake, live
server, `/api/settings/require-login`, `/api/auth/status`) goes through that helper.
Other JWTs signed with `JWT_SECRET` exist — the Cursor CLI passthrough mints
`iss "omniroute" / aud "cursor-cli"` tokens for key holders — and are never sessions
(#13298).
Verified by `isDashboardSessionAuthenticated()` in `src/shared/utils/apiAuth.ts`. The pipeline auto-refreshes the JWT when it has fewer than 7 days left in its 30-day lifetime.
Some management routes accept **either** mode: cookie OR `Bearer <key>` when the API key has the `manage` (or `admin`) scope. This is what enables the "configurable via API calls" workflow added in v3.8.

View File

@@ -338,8 +338,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) |
| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image default above. Coding agents: `8192`+ (see [runtime RAM](#runtime-ram-for-coding-agents)). | `1024` |
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
| `APP_BIND_HOST` | Host interface docker-compose publishes the dashboard/API/live-WS ports on. With `REQUIRE_API_KEY=false` (the default), `0.0.0.0` exposes the anonymous `/v1` proxy to the LAN — only widen with `REQUIRE_API_KEY=true` or a reverse proxy in front. | `127.0.0.1` |
| `CLIPROXY_BIND_HOST` | Host interface docker-compose publishes the `cliproxyapi` sidecar on — its data volume holds provider credentials. | `127.0.0.1` |
| `OMNIROUTE_PLUGINS_DIR` | Directory the runtime plugin scanner reads and installs into. Set it when plugins are bind-mounted: the default follows `HOME`, which an image need not export. | `~/.omniroute/plugins` |
| `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ |
| `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset |

View File

@@ -77,7 +77,7 @@ naming the endpoint and what was lost — so a degraded picker is never a myster
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `providerId` | `"omniroute"` | Provider id, integration id, and the prefix models appear under |
| `baseURL` | required | Gateway root; the `/v1` suffix is added where needed |
| `baseURL` | required | Gateway root, `http(s)` only; the `/v1` suffix is added where needed |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` |
| `managementReadToken` | falls back to `apiKey` | Key for `/api/*` — usually **not** the same one |
| `displayName` | `"OmniRoute"` | Provider name in the picker |

View File

@@ -1072,7 +1072,6 @@ desktop install.
| `CLIPROXYAPI_API_KEY` | _(empty)_ | `open-sse/handlers/chatCore/cliproxyapiCredentials.ts` | Data-plane key fallback when the `cliproxyapi_api_key` setting is absent. |
| `CLIPROXYAPI_MANAGEMENT_KEY` | _(empty)_ | `src/lib/services/cliproxyAccountHealth.ts` | Management key for account-health reads from an externally managed CLIProxyAPI instance. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `CLIPROXY_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the `cliproxyapi` sidecar on (#12578). Its data volume holds provider OAuth/API credentials and the pinned image has no env-based data-plane `api-keys` override (only a mounted `config.yaml`), so `0.0.0.0` exposes a credential-bearing service to the whole LAN. |
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
@@ -1382,9 +1381,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
| `APP_BIND_HOST` | `127.0.0.1` | `docker-compose.yml`, `docker-compose.prod.yml` | Host interface docker-compose publishes the app's own dashboard/API/live-WS ports on (#12568). With `REQUIRE_API_KEY=false` shipping as the `.env.example` default, `0.0.0.0` exposes the anonymous `/v1` LLM proxy to the whole LAN/WAN — only widen once `REQUIRE_API_KEY=true` or a reverse proxy in front enforces its own auth. |
| `QDRANT_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Qdrant memory sidecar on (#12578). Same LAN-exposure reasoning as `REDIS_BIND_HOST`. |
| `BIFROST_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Bifrost router sidecar on (#12578). Same LAN-exposure reasoning as `REDIS_BIND_HOST`. |
| `REDIS_KEY_PREFIX` | `omniroute:` | `src/shared/utils/rateLimiter.ts` | Namespace prefix applied to every OmniRoute Redis key (rate limiter, auth cache, quota store). Prevents key collisions when the Redis instance is shared with other apps (#11042). |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
@@ -1627,6 +1623,7 @@ These settings were introduced after the previous environment-contract snapshot.
| `ADOBE_FIREFLY_CHROME_HEADLESS` | `0` | `open-sse/services/adobeFireflyBrowserLogin.ts` | Debug-only true-headless mode; Adobe colligo normally rejects the resulting risk session. |
| `CHROME_PATH` | auto-detect | `open-sse/executors/cloudflare-playground.ts`, `open-sse/executors/chatgpt-web-codex.ts` | Optional absolute Chrome executable used by the browser-driven executors when platform auto-detection is insufficient. |
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | BotFather token that enables the inbound webhook and signs Mini App `initData`. |
| `TELEGRAM_WEBHOOK_SECRET` | _(unset)_ | `src/lib/telegram/config.ts` | Shared secret registered via `setWebhook` and verified against the `X-Telegram-Bot-Api-Secret-Token` header on every webhook delivery. Required for the webhook path; unset means webhook deliveries are refused with 503. |
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |

View File

@@ -2113,9 +2113,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",

View File

@@ -90,13 +90,19 @@ export function getBedrockKnownModelLimits(modelId: string): {
if (!trimmed) return null;
const unqualified = trimmed.includes("/") ? trimmed.slice(trimmed.indexOf("/") + 1) : trimmed;
const withoutProfilePrefix = unqualified.replace(/^(?:eu|us|global)\./i, "");
const withoutProviderPrefix = withoutProfilePrefix.replace(/^anthropic\./i, "");
const spec =
getModelSpec(trimmed) ||
getModelSpec(unqualified) ||
getModelSpec(withoutProfilePrefix) ||
getModelSpec(withoutProviderPrefix);
// A Bedrock id is "<vendor>.<model>" optionally behind a cross-region profile
// prefix: "global.openai.gpt-5.6-sol", "us.anthropic.claude-...". The model
// name itself contains dots ("gpt-5.6-sol"), so peel at most those two leading
// qualifiers and keep the first candidate a spec knows. Peeling only
// "anthropic." left every other vendor (openai, meta, amazon, ...) without a
// context window, and the caller then fell back to a 200k default (#12915).
const segments = unqualified.split(".");
const spec = [trimmed, unqualified, segments.slice(1).join("."), segments.slice(2).join(".")]
.filter((candidate) => candidate.length > 0)
.reduce<ReturnType<typeof getModelSpec>>(
(found, candidate) => found || getModelSpec(candidate),
undefined
);
if (!spec?.contextWindow && !spec?.maxOutputTokens) return null;
return {

View File

@@ -32,7 +32,7 @@ export type ProviderErrorRuleMatch = {
/**
* Intended lock scope. #10334: for a BUILT-IN catalog rule, this field is
* CONSUMED end-to-end only for providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS`
* (agentrouter-exclusive today, gated by `honorsRuleLockScope()`) — for those,
* (agentrouter + the opencode family, gated by `honorsRuleLockScope()`) — for
* `checkFallbackError` surfaces it as `ruleScope` on its return value for the
* persistence layer to honor instead of re-deriving scope from
* `hasPerModelQuota()`. For every other built-in-rule provider it remains
@@ -155,6 +155,19 @@ function buildOpencodeRules(): ProviderErrorRule[] {
return null;
},
},
{
id: "opencode-400-model-unavailable",
match: ({ status, body }) => {
if (status !== 400) return null;
const text = JSON.stringify(body ?? "").toLowerCase();
if (!text.includes("upstream request failed: model is unavailable.")) return null;
return {
reason: "model_capacity",
scope: "model",
cooldownMs: 3_600_000,
};
},
},
];
}
@@ -290,15 +303,16 @@ function buildAgentrouterRules(): ProviderErrorRule[] {
];
}
/** Providers sharing the opencode upstream envelope, hence the opencode catalog rules. */
const OPENCODE_RULE_FAMILY = ["opencode", "opencode-zen", "opencode-go", "opencode-cli"];
/**
* Global registry. Provider name → ordered list of rules (first match wins).
* Add new providers here; the matcher in classifyError will pick them up
* automatically.
*/
export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
["opencode", buildOpencodeRules()],
["opencode-go", buildOpencodeRules()],
["opencode-cli", buildOpencodeRules()],
...OPENCODE_RULE_FAMILY.map((id): [string, ProviderErrorRule[]] => [id, buildOpencodeRules()]),
["minimax", buildMinimaxRules()],
["minimax-passthrough", buildMinimaxRules()],
["cloudflare-ai", buildCloudflareAiRules()],
@@ -323,7 +337,7 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
* mechanism (#11104) silently inert for every provider except the ones listed
* below. See `hasOperatorRuleForProvider`.
*/
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter", ...OPENCODE_RULE_FAMILY]);
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
if (!provider) return false;
@@ -509,3 +523,21 @@ export function parseResetCountdownMs(text: string): number | null {
return null;
}
}
/**
* Opencode-family "Upstream request failed: Model is unavailable." 400: the rule's
* model-scope match, or null for any other provider, status or rule. Takes the raw
* error text so it stays independent of FULL_TEXT_RULE_PROVIDERS (#10880).
*/
export function getOpencodeModelUnavailableMatch(
provider: string | null | undefined,
status: number,
headers: Headers | Record<string, string> | null | undefined,
errorText: unknown
): ProviderErrorRuleMatch | null {
if (status !== 400 || !provider || !OPENCODE_RULE_FAMILY.includes(provider.toLowerCase())) {
return null;
}
const match = getProviderErrorRuleMatch(provider, status, headers, errorText);
return match?.scope === "model" && match.reason === "model_capacity" ? match : null;
}

View File

@@ -249,6 +249,8 @@ import { electronhubProvider } from "./registry/electronhub/index.ts";
import { llmgatewayProvider } from "./registry/llmgateway/index.ts";
import { llmKiwiProvider } from "./registry/llm-kiwi/index.ts";
import { literouterProvider } from "./registry/literouter/index.ts";
import { greenptProvider } from "./registry/greenpt/index.ts";
import { eurouterProvider } from "./registry/eurouter/index.ts";
import { mnnAiProvider } from "./registry/mnn-ai/index.ts";
import { meganovaAiProvider } from "./registry/meganova-ai/index.ts";
import { mixlayerProvider } from "./registry/mixlayer/index.ts";
@@ -524,6 +526,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
llmgateway: llmgatewayProvider,
"llm-kiwi": llmKiwiProvider,
literouter: literouterProvider,
greenpt: greenptProvider,
eurouter: eurouterProvider,
"mnn-ai": mnnAiProvider,
"meganova-ai": meganovaAiProvider,
mixlayer: mixlayerProvider,

View File

@@ -0,0 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const eurouterProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "eurouter",
alias: "eurouter",
baseUrl: "https://api.eurouter.ai/v1/chat/completions",
modelsUrl: "https://api.eurouter.ai/v1/models",
models: [],
passthroughModels: true,
});

View File

@@ -0,0 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const greenptProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "greenpt",
alias: "greenpt",
baseUrl: "https://api.greenpt.ai/v1/chat/completions",
modelsUrl: "https://api.greenpt.ai/v1/models",
models: [],
passthroughModels: true,
});

View File

@@ -20,15 +20,24 @@
/**
* Deployments that require `max_completion_tokens` instead of `max_tokens`.
*
* Matches the GPT-5 family and the o1/o3/o4 reasoning series at a token
* Matches GPT-5 and later, and the o1/o3/o4 reasoning series, at a token
* boundary, so a deployment named `my-gpt-5-prod` matches while an unrelated
* `piston-o4-legacy`-style name does not match by accident. `gpt-chat-latest`
* is listed explicitly: it is a moving alias that currently resolves to a
* GPT-5-era model and rejects `max_tokens`, but carries no version number for
* the boundary pattern to key on.
*
* The generation is a range rather than a literal `gpt-5`, because the rule is
* a property of the generation and not of one release: `gpt-6-astra` rejects
* `max_tokens` for exactly the reason `gpt-5` does, and pinning the literal
* meant every new family arrived broken (#12981).
*
* It is a range and not `\d+` on purpose. Azure's own name for GPT-3.5 is
* `gpt-35-turbo`, which takes `max_tokens` and would be caught by a digit-run.
* `1\d` keeps a future `gpt-10` working without letting `gpt-35` in.
*/
export const AZURE_COMPLETION_TOKEN_DEPLOYMENT =
/(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)|^gpt-chat-latest$/i;
/(?:^|[/_-])(?:gpt-(?:[5-9]|1\d)|o(?:1|3|4))(?:[._-]|$)|^gpt-chat-latest$/i;
/**
* Apply the Azure param rules to an already-translated Chat Completions body.

View File

@@ -211,6 +211,8 @@ export type ExecuteInput = {
) => Promise<void> | void;
/** When true, skip the intra-URL 429 retry in execute() so the caller handles fallback. */
skipUpstreamRetry?: boolean;
/** Request-scoped id for log attribution; absent off the chat path, never fabricated. */
correlationId?: string | null;
/** Delegated Context Editing (Claude only): when enabled, attach the
* `context_management.clear_tool_uses` strategy so the provider clears stale
* tool-use blocks server-side. Honored only on the genuine `claude` path. */

View File

@@ -216,6 +216,9 @@ function translateAnthropicJsonError(parsed: unknown): JsonRecord {
};
}
/** 64 KB queue budget for GLM streaming (#12179, wired through in #12925). */
const GLM_STREAM_BUFFER_BYTES = 65536;
export function translateSseResponse(
response: Response,
provider: string,
@@ -223,8 +226,11 @@ export function translateSseResponse(
suppressThinkClose: boolean = false
): Response {
if (!response.body) return response;
// Helper has 15 parameters; a 16th positional (65536) was a TS2554 and
// never reached TransformStream. highWaterMark stays at the helper default.
// GLM is a high-throughput provider: a 64 KB queue budget keeps provider ->
// client pacing ahead of the model's emission rate. #12179 asked for this by
// passing a 16th positional the helper did not take (a TS2554 that never
// reached the TransformStream); the helper now accepts it as its last
// parameter, so the request finally takes effect (#12925).
const transform = createSSETransformStreamWithLogger(
FORMATS.CLAUDE,
FORMATS.OPENAI,
@@ -238,7 +244,10 @@ export function translateSseResponse(
null,
null,
false,
suppressThinkClose
suppressThinkClose,
undefined,
undefined,
GLM_STREAM_BUFFER_BYTES
);
const headers = cloneHeaders(response.headers);
headers.set("content-type", "text/event-stream");

View File

@@ -29,6 +29,7 @@ import {
extractChatcmplId,
} from "./accountRotation.ts";
import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts";
import { isRetriableUpstreamFailure } from "./opencodeTransientFailure.ts";
import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags";
/**
@@ -504,6 +505,10 @@ export class OpencodeExecutor extends BaseExecutor {
this.syncAccountsFromCredentials(input.credentials);
const { log } = input;
// Request-scoped attribution prefix for rotation logs: message head,
// empty when absent (never n/a/none/fabricated). The existing motif
// stays byte-identical after the prefix.
const cid = input.correlationId ? `correlationId=${input.correlationId} ` : "";
const hasProxies = this.accounts.some((a) => a.proxy !== null);
// Fast path: no multi-account proxy wiring configured → original behavior,
@@ -533,7 +538,7 @@ export class OpencodeExecutor extends BaseExecutor {
const chatcmplId = extractChatcmplId(bodyText);
log?.warn?.(
"OPENCODE",
`upstream empty rejection on direct account (${chatcmplId}), retrying once…`
`${cid}upstream empty rejection on direct account (${chatcmplId}), retrying once…`
);
return this.normalizeMuseSparkResponse(input, await super.execute(input));
}
@@ -567,8 +572,9 @@ export class OpencodeExecutor extends BaseExecutor {
// through the accounts is the retry). Avoids an unbounded loop on a
// persistently malformed upstream.
const emptyRejectionBudget = this.accounts.length === 1 ? 1 : 0;
// 403-geo tried set: proxy keys already proven geo-blocked for this
// request's model. Request-local only — nothing persists past execute().
// Tried set: proxy keys already proven unusable for this request's
// model (geo-blocked, or transient 5xx). Request-local only — nothing
// persists past execute().
const geoTriedProxyKeys = new Set<string>();
let directTried = false;
@@ -594,15 +600,19 @@ export class OpencodeExecutor extends BaseExecutor {
}
const lastStatus = lastResult !== null ? lastResult.response.status : null;
const lastWasGeo = lastStatus === 403 || lastStatus === 451;
const lastWasTransient = lastStatus !== null && lastStatus >= 500 && lastStatus < 600;
const isMonoRetryOwed = this.accounts.length === 1 && lastWasTransient;
if (
!isMonoRetryOwed &&
lastResult !== null &&
geoTriedProxyKeys.size > 0 &&
!isProxiedCandidate(account) &&
!(account.proxy === null && !directTried)
) {
// Geo exhaustion (last was 403/451) → surface as-is, no success mark.
// Transient exhaustion (last was 5xx) → same: surface last as-is.
// Any other last status (e.g. 429 after 403s) → skip without a call.
if (lastWasGeo) break;
if (lastWasGeo || lastWasTransient) break;
continue;
}
// Commit the last-resort direct attempt so a later exclusion breaks
@@ -614,7 +624,7 @@ export class OpencodeExecutor extends BaseExecutor {
if (sharedEgressGuardEnabled && sharedEgressDown && !account.proxy) {
log?.warn?.(
"OPENCODE",
`skipping account ${masked} (no dedicated proxy, shared egress already down this request)`
`${cid}skipping account ${masked} (no dedicated proxy, shared egress already down this request)`
);
continue;
}
@@ -625,7 +635,7 @@ export class OpencodeExecutor extends BaseExecutor {
// Token stays masked — never log the full account id.
log?.info?.(
"OPENCODE",
`dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` +
`${cid}dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` +
(account.proxy
? ` through proxy ${account.proxy.host}:${account.proxy.port}`
: " direct")
@@ -657,20 +667,20 @@ export class OpencodeExecutor extends BaseExecutor {
lastSharedEgressError = err;
log?.warn?.(
"OPENCODE",
`network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${reason})`
`${cid}network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${reason})`
);
continue;
}
log?.warn?.(
"OPENCODE",
`network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${reason})`
`${cid}network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${reason})`
);
throw err;
}
this.markCooldown(account);
log?.warn?.(
"OPENCODE",
`network error on account ${masked}, rotating to next… (${reason})`
`${cid}network error on account ${masked}, rotating to next… (${reason})`
);
continue;
}
@@ -679,7 +689,28 @@ export class OpencodeExecutor extends BaseExecutor {
const status = result.response.status;
if (status === 429) {
this.markCooldown(account);
log?.warn?.("OPENCODE", `Rate limited (429) on account ${masked}, rotating to next…`);
log?.warn?.(
"OPENCODE",
`${cid}Rate limited (429) on account ${masked}, rotating to next…`
);
continue;
}
if (isRetriableUpstreamFailure(status)) {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
log?.warn?.(
"OPENCODE",
`${cid}transient upstream ${status} on account ${masked} (proxy ${key ?? "direct"}), rotating to next…`
);
// Deliberately a separate branch from the 400-empty arm below,
// not one merged `if`: this arm never touches the body, the 400
// arm must clone-read it. Both share the predicate + tried-set.
// Single proxied account: one retry via the existing budget (a
// proxy-less single account takes the fast path, never the loop).
// Transient is not deterministic like geo: upstream may recover.
// No 0-retry guard here (it stays geo-only).
continue;
}
@@ -696,7 +727,7 @@ export class OpencodeExecutor extends BaseExecutor {
else directTried = true;
log?.warn?.(
"OPENCODE",
`geo-blocked on account ${masked} (proxy ${key ?? "direct"}), rotating to next…`
`${cid}geo-blocked on account ${masked} (proxy ${key ?? "direct"}), rotating to next…`
);
// Single account with a proxy: 0 retries (same egress = dead latency).
// (The fast path above already covers single-without-proxy; here length===1 WITH proxy.)
@@ -719,11 +750,11 @@ export class OpencodeExecutor extends BaseExecutor {
} catch {
log?.debug?.("OPENCODE", "body read failed on empty rejection check");
}
if (bodyText !== null && isEmptyUpstreamRejection(400, bodyText)) {
if (bodyText !== null && isRetriableUpstreamFailure(400, bodyText)) {
const chatcmplId = extractChatcmplId(bodyText);
log?.warn?.(
"OPENCODE",
`upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…`
`${cid}upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…`
);
continue;
}

View File

@@ -0,0 +1,17 @@
/**
* opencodeTransientFailure.ts — retriable-upstream predicate for the opencode
* executor loop.
*
* Leaf module: one internal import only (isEmptyUpstreamRejection, same
* executors layer — no registry, no DB). 5xx short-circuits on status alone;
* the 400 arm delegates to the existing empty-rejection classifier.
*/
import { isEmptyUpstreamRejection } from "./accountRotation.ts";
export function isRetriableUpstreamFailure(status: number, bodyText?: string): boolean {
if (status >= 500 && status < 600) return true;
if (status !== 400) return false;
if (typeof bodyText !== "string" || bodyText === "") return false;
return isEmptyUpstreamRejection(status, bodyText);
}

View File

@@ -3167,6 +3167,7 @@ export async function handleChatCore({
onCredentialsRefreshed,
skipUpstreamRetry,
contextEditing: { enabled: contextEditingEnabled },
correlationId,
})
),
});
@@ -3353,6 +3354,7 @@ export async function handleChatCore({
onCredentialsRefreshed,
skipUpstreamRetry,
contextEditing: { enabled: contextEditingEnabled },
correlationId,
})
),
});
@@ -4009,6 +4011,7 @@ export async function handleChatCore({
onCredentialsRefreshed,
skipUpstreamRetry: isCombo,
contextEditing: { enabled: contextEditingEnabled },
correlationId,
})
)
);

View File

@@ -88,33 +88,46 @@ async function sniffJsonBodyForSse(
let sniffed = "";
let sniffedBytes = 0;
const maxSniffBytes = 4096;
while (sniffedBytes < maxSniffBytes) {
const chunk = await deps.withBodyTimeout<ReadableStreamReadResult<Uint8Array>>(reader.read());
if (chunk.done || !chunk.value) break;
bufferedChunks.push(chunk.value);
sniffedBytes += chunk.value.byteLength;
sniffed += decoder.decode(chunk.value, { stream: true });
// The two success paths below hand this still-open reader to
// prependBufferedChunks(), so the reader must NOT be cancelled on the happy
// path. Any other unwind (notably a withBodyTimeout rejection on a stalled
// upstream) would otherwise abandon the body with no cancellation, pinning
// the connection for the lifetime of the socket.
let handedOff = false;
try {
while (sniffedBytes < maxSniffBytes) {
const chunk = await deps.withBodyTimeout<ReadableStreamReadResult<Uint8Array>>(reader.read());
if (chunk.done || !chunk.value) break;
bufferedChunks.push(chunk.value);
sniffedBytes += chunk.value.byteLength;
sniffed += decoder.decode(chunk.value, { stream: true });
if (classifyBodyPrefix(sniffed) === "sse") {
const rebuiltHeaders = new Headers(providerResponse.headers);
rebuiltHeaders.delete("content-length");
rebuiltHeaders.set("content-type", "text/event-stream");
ctx.log?.debug?.(
"STREAM",
`Upstream returned SSE bytes with application/json content-type — preserving streaming body (${ctx.provider}/${ctx.model})`
);
return {
sseResponse: new Response(prependBufferedChunks(bufferedChunks, reader), {
status: providerResponse.status,
statusText: providerResponse.statusText,
headers: rebuiltHeaders,
}),
jsonBody: new Response(null),
};
if (classifyBodyPrefix(sniffed) === "sse") {
const rebuiltHeaders = new Headers(providerResponse.headers);
rebuiltHeaders.delete("content-length");
rebuiltHeaders.set("content-type", "text/event-stream");
ctx.log?.debug?.(
"STREAM",
`Upstream returned SSE bytes with application/json content-type — preserving streaming body (${ctx.provider}/${ctx.model})`
);
handedOff = true;
return {
sseResponse: new Response(prependBufferedChunks(bufferedChunks, reader), {
status: providerResponse.status,
statusText: providerResponse.statusText,
headers: rebuiltHeaders,
}),
jsonBody: new Response(null),
};
}
}
}
return { jsonBody: new Response(prependBufferedChunks(bufferedChunks, reader)) };
handedOff = true;
return { jsonBody: new Response(prependBufferedChunks(bufferedChunks, reader)) };
} finally {
// Cancellation is best-effort: the body may already be errored or closed.
if (!handedOff) void reader.cancel().catch(() => {});
}
}
export async function maybeConvertJsonBodyToSse(

View File

@@ -16,6 +16,7 @@ import {
isNimFunctionDegraded,
} from "../config/errorConfig.ts";
import {
getOpencodeModelUnavailableMatch,
getProviderErrorRuleMatch,
resolveRuleMatchBody,
honorsRuleLockScope,
@@ -1792,6 +1793,18 @@ export function checkFallbackError(
return profile?.useUpstreamRetryHints ? detectRetryHint() : null;
}
function ruleScopedResult(match: NonNullable<ReturnType<typeof getProviderErrorRuleMatch>>) {
const scaled = getScaledBaseCooldown(match.reason as RateLimitReasonValue, backoffLevel);
return {
shouldFallback: true,
cooldownMs: match.cooldownMs ?? scaled.cooldownMs,
baseCooldownMs: match.cooldownMs ?? scaled.baseCooldownMs,
configuredCooldownMs: match.cooldownMs,
newBackoffLevel: match.cooldownMs !== undefined ? 0 : scaled.newBackoffLevel,
reason: match.reason,
ruleScope: match.scope,
};
}
function getScaledBaseCooldown(reason: RateLimitReasonValue, level = backoffLevel) {
void reason;
const baseCooldownMs =
@@ -2065,22 +2078,7 @@ export function checkFallbackError(
headers,
resolveRuleMatchBody(provider, structuredError ?? null, errorStr)
);
if (forbiddenMatch) {
const scaled = getScaledBaseCooldown(
forbiddenMatch.reason as RateLimitReasonValue,
backoffLevel
);
const ruleCooldownMs = forbiddenMatch.cooldownMs;
return {
shouldFallback: true,
cooldownMs: ruleCooldownMs ?? scaled.cooldownMs,
baseCooldownMs: ruleCooldownMs ?? scaled.baseCooldownMs,
configuredCooldownMs: ruleCooldownMs,
newBackoffLevel: ruleCooldownMs !== undefined ? 0 : scaled.newBackoffLevel,
reason: forbiddenMatch.reason,
ruleScope: forbiddenMatch.scope,
};
}
if (forbiddenMatch) return ruleScopedResult(forbiddenMatch);
}
if (
@@ -2199,6 +2197,8 @@ export function checkFallbackError(
// 400 — context overflow / malformed request / model access denied
if (status === HTTP_STATUS.BAD_REQUEST) {
const modelUnavailable = getOpencodeModelUnavailableMatch(provider, status, headers, errorStr);
if (modelUnavailable) return ruleScopedResult(modelUnavailable);
// Check structured error codes first (more reliable, no false positives)
// OpenAI: error.code === "model_not_found"
// Anthropic: error.type === "not_found_error" / "permission_error"
@@ -2321,7 +2321,8 @@ export function formatRetryAfter(
rateLimitedUntil: string | number | Date | null | undefined
): string {
if (!rateLimitedUntil) return "";
const diffMs = new Date(rateLimitedUntil).getTime() - Date.now();
const diffMs = cooldownUntilMs(rateLimitedUntil) - Date.now();
if (!Number.isFinite(diffMs)) return "";
if (diffMs <= 0) return "reset after 0s";
const totalSec = Math.ceil(diffMs / 1000);
const h = Math.floor(totalSec / 3600);

View File

@@ -16,7 +16,11 @@ import {
isLocalExecutionError,
isModelCapacityOverloadError,
} from "@/shared/utils/circuitBreaker";
import { CONTEXT_OVERFLOW_PATTERNS, MODEL_ACCESS_DENIED_PATTERNS } from "../accountFallback.ts";
import {
CONTEXT_OVERFLOW_PATTERNS,
MODEL_ACCESS_DENIED_PATTERNS,
cooldownUntilMs,
} from "../accountFallback.ts";
import { isResourceNotFoundResponse } from "../errorClassifier.ts";
import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts";
import type { ResolvedComboTarget } from "./types.ts";
@@ -476,7 +480,9 @@ export function normalizeConnectionStatus(value: unknown): string {
export function hasFutureRateLimitUntil(value: unknown): boolean {
if (value == null || value === "") return false;
const time = new Date(String(value)).getTime();
if (typeof value !== "string" && typeof value !== "number" && !(value instanceof Date))
return false;
const time = cooldownUntilMs(value);
return Number.isFinite(time) && time > Date.now();
}

View File

@@ -27,9 +27,11 @@ import {
import { RateLimitReason } from "../../config/constants.ts";
import { isProviderCircuitOpenResult, isRequestScopedUpstreamFailure } from "./comboPredicates.ts";
import { isCloudflareFingerprintRejection } from "../errorClassifier.ts";
// #10334 — agentrouter-exclusive predicate shared with the persistence layer
// #10334 — connection-scope predicate shared with the persistence layer
// (markAccountUnavailable) so the same-request combo skip and the persisted
// connection cooldown agree on exactly which fallbackResult shapes qualify.
// Exclusive in practice to agentrouter's "额度不足" rule: no opencode-family
// rule matches 403 today, so only agentrouter reaches this predicate via 403.
import { isAgentrouterConnectionQuotaScope } from "@/sse/services/auth";
import type { ComboLogger, ResolvedComboTarget } from "./types.ts";
@@ -84,9 +86,9 @@ export type ComboExhaustionSets = {
export type ApplyComboTargetExhaustionOptions = {
result: { status: number; headers?: Headers | null };
fallbackResult: Parameters<typeof isProviderExhaustedReason>[0] & {
/** #10334 — agentrouter-exclusive; see isAgentrouterConnectionQuotaScope
/** #10334 — agentrouter + opencode family; see isAgentrouterConnectionQuotaScope
* (src/sse/services/auth.ts). Populated only for providers in
* HONORS_RULE_LOCK_SCOPE_PROVIDERS (today: agentrouter only). */
* HONORS_RULE_LOCK_SCOPE_PROVIDERS (agentrouter + opencode family). */
ruleScope?: "model" | "provider" | "connection";
permanent?: boolean;
};
@@ -115,7 +117,8 @@ export function applyComboTargetExhaustion(
const { result, sets, log, tag, errorText, structuredError } = opts;
const provider = target.provider;
// #10334: agentrouter-exclusive account-wide quota exhaustion ("额度不足")
// #10334: connection-scope account-wide quota exhaustion (agentrouter "额度不足";
// exclusive in practice — no opencode-family rule matches 403 today)
// must skip remaining SAME-CONNECTION targets within THIS request too, not
// just via the persisted cooldown markAccountUnavailable applies for
// whichever leg runs next. agentrouter is a passthroughModels provider
@@ -341,7 +344,8 @@ function markAuthLevelExhaustion(
}
/**
* #10334: agentrouter-exclusive connection-scope account quota exhaustion. Mirrors
* #10334: connection-scope account quota exhaustion (agentrouter-exclusive in
* practice — see above). Mirrors
* markAuthLevelExhaustion's connectionId-present/absent split — when the target carries a
* connectionId, only that connection's account is exhausted (sibling agentrouter connections
* for the same user may still have quota); fall back to whole-provider exhaustion only when no

View File

@@ -131,7 +131,7 @@ export class CompressionWorkerPool {
}
async close(): Promise<void> {
for (const job of this.queue.splice(0)) job.resolve(unchanged(job.originalBody));
await Promise.all([...this.workers].map((slot) => this.remove(slot, true)));
await Promise.all([...this.workers].map((slot) => this.remove(slot)));
}
private spawn(): PoolWorker {
const slot: PoolWorker = {
@@ -185,7 +185,10 @@ export class CompressionWorkerPool {
slot.timeout = null;
slot.job = null;
job.resolve(result);
slot.idle = setTimeout(() => void this.remove(slot, false), this.idleMs);
// Idle eviction MUST terminate. Dropping the slot from the set only releases our
// reference - the thread, its MessagePort and its private heap outlive the pool
// for the whole process lifetime, invisible to process.memoryUsage(). (#12812)
slot.idle = setTimeout(() => void this.remove(slot), this.idleMs);
slot.idle.unref();
this.dispatch();
}
@@ -193,13 +196,15 @@ export class CompressionWorkerPool {
const job = slot.job;
if (job) job.resolve(unchanged(job.originalBody));
slot.job = null;
void this.remove(slot, true).finally(() => this.dispatch());
void this.remove(slot).finally(() => this.dispatch());
}
private async remove(slot: PoolWorker, terminate: boolean): Promise<void> {
/** Drop a slot and release its OS thread. Removal always terminates: a pooled worker
* has no other owner, so skipping terminate() strands the thread permanently. */
private async remove(slot: PoolWorker): Promise<void> {
if (!this.workers.delete(slot)) return;
if (slot.timeout) clearTimeout(slot.timeout);
if (slot.idle) clearTimeout(slot.idle);
if (terminate) await slot.worker.terminate().catch(() => undefined);
await slot.worker.terminate().catch(() => undefined);
}
}

View File

@@ -234,7 +234,12 @@ function ensureWorker(): Worker {
const { workerFile, execArgv } = resolveWorkerFile();
const absoluteWorkerFile = path.resolve(workerFile);
const w = new Worker(pathToFileURL(absoluteWorkerFile).href, { execArgv });
// Pass the URL OBJECT, not `.href`. `new Worker()` treats a plain string as a
// filesystem path, so a "file://..." string is looked up literally and throws
// ERR_WORKER_PATH (a string arg must start with ./ or ../). Only a URL instance
// is interpreted as a file: URL. Spawn failures are swallowed by pump()'s catch,
// so getting this wrong silently disables compression instead of erroring.
const w = new Worker(pathToFileURL(absoluteWorkerFile), { execArgv });
w.on("message", (reply: WorkerReply) => {
const entry = pending.get(reply.id);

View File

@@ -22,6 +22,12 @@ export function isTextBlock(value: unknown): value is TextBlock {
);
}
export function isToolResultBlock(value: unknown): boolean {
return (
!!value && typeof value === "object" && (value as { type?: unknown }).type === "tool_result"
);
}
export function extractTextContent(content: ChatMessageLike["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
@@ -82,7 +88,14 @@ export function replaceTextContent(msg: ChatMessageLike, newText: string): ChatM
});
if (!replaced) {
return { ...msg, content: [{ type: "text", text: newText }, ...msg.content] };
// Anthropic requires every `tool_result` block to sit at the start of the
// user turn that answers a `tool_use`; a text block in front of them makes
// upstream reject the whole request with "tool_use ids were found without
// tool_result blocks immediately after" (#12890). Append the annotation in
// that case, and keep prepending everywhere else.
return msg.content.some(isToolResultBlock)
? { ...msg, content: [...msg.content, { type: "text", text: newText }] }
: { ...msg, content: [{ type: "text", text: newText }, ...msg.content] };
}
return { ...msg, content };

View File

@@ -514,6 +514,13 @@ const SCHEMA_SLOT_KEYS = [
"else",
"unevaluatedProperties",
"additionalItems",
// draft 2020-12 applicators whose value is a schema too. Without them a
// placeholder in either position falls through to the scalar branch at the
// bottom of the walker and is forwarded as a string, which is the shape this
// sanitizer exists to remove. The opencode plugin's own walker
// (@omniroute/opencode-plugin-v2/src/shared/gemini.ts) lists both.
"contentSchema",
"unevaluatedItems",
];
function coerceIndexedObjectToArray(value: unknown): unknown[] | null {

View File

@@ -145,6 +145,9 @@ type StreamCompletePayload = {
interrupted?: boolean;
};
/** Queue budget every provider used before `streamBufferBytes` existed. */
const DEFAULT_STREAM_BUFFER_BYTES = 16384;
type StreamOptions = {
mode?: string;
targetFormat?: string;
@@ -160,6 +163,14 @@ type StreamOptions = {
*/
dropResponsesCommentary?: boolean;
customToolNames?: ReadonlySet<string>;
/**
* Byte budget for the transform's readable and writable queues.
*
* Defaults to the 16 KB every provider used before this was configurable. A
* high-throughput provider can raise it so provider -> client pacing stays
* ahead of the model's emission rate; nothing else should need to.
*/
streamBufferBytes?: number;
provider?: string | null;
reqLogger?: StreamLogger | null;
toolNameMap?: unknown;
@@ -655,6 +666,7 @@ export function createSSEStream(options: StreamOptions = {}) {
dropResponsesCommentary,
customToolNames = new Set<string>(),
requestToolIdentityMap = null,
streamBufferBytes = DEFAULT_STREAM_BUFFER_BYTES,
} = options;
const signatureNamespace = connectionId;
// Request-body-size metric (for monitoring payload size distribution & correlation with TTFT).
@@ -1103,7 +1115,8 @@ export function createSSEStream(options: StreamOptions = {}) {
cacheHit: false,
latencyMs: Date.now() - streamStartedAt,
usage: timing.withTps(finalUsage),
costUsd, ttftMs: timing.ttftMs(),
costUsd,
ttftMs: timing.ttftMs(),
});
if (!comment) return;
reqLogger?.appendConvertedChunk?.(comment);
@@ -2069,7 +2082,9 @@ export function createSSEStream(options: StreamOptions = {}) {
// estimate is now emitted in flush(), only when the upstream stayed silent.
if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
const buffered = addBufferToUsage(usage);
parsed.usage = timing.withTps(filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI));
parsed.usage = timing.withTps(
filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI)
);
output = `data: ${JSON.stringify(parsed)}\n\n`;
passthroughForwardedUsage = true;
injectedUsage = true;
@@ -3020,8 +3035,8 @@ export function createSSEStream(options: StreamOptions = {}) {
clearIdleTimer();
},
},
{ highWaterMark: 16384 },
{ highWaterMark: 16384 }
{ highWaterMark: streamBufferBytes },
{ highWaterMark: streamBufferBytes }
);
}
@@ -3043,7 +3058,8 @@ export function createSSETransformStreamWithLogger(
copilotCompatibleReasoning = false,
suppressThinkClose = false,
customToolNames: ReadonlySet<string> = new Set(),
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null,
streamBufferBytes: number = DEFAULT_STREAM_BUFFER_BYTES
) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
@@ -3062,6 +3078,7 @@ export function createSSETransformStreamWithLogger(
suppressThinkClose,
customToolNames,
requestToolIdentityMap,
streamBufferBytes,
});
}

50
package-lock.json generated
View File

@@ -8687,20 +8687,6 @@
"license": "ISC",
"optional": true
},
"node_modules/@openai/codex-security/node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/@openai/codex-security/node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
@@ -15217,9 +15203,9 @@
}
},
"node_modules/@yarnpkg/parsers/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -18535,9 +18521,9 @@
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.1.tgz",
"integrity": "sha512-+2z7Ar0APQ7Uu6fX4cn+pitRmxjZ1WPBcGmZFKmA74FCyi7Et/XZx8cjNQ5CjbZ4HCOxXCOpRBYvYH08Qa003A==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
"integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
"dev": true,
"license": "MIT"
},
@@ -23603,9 +23589,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -26116,9 +26102,9 @@
}
},
"node_modules/joi": {
"version": "18.2.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
"version": "18.2.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -27946,9 +27932,9 @@
}
},
"node_modules/lockfile-lint/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -39620,9 +39606,9 @@
}
},
"node_modules/xmlbuilder2/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{

View File

@@ -125,8 +125,8 @@
"electron:build:mac": "npm run build && cd electron && npm run build:mac",
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
"electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs",
"test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\"",
"test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\"",
"test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
@@ -486,7 +486,10 @@
"fast-uri": "^3.1.7",
"body-parser": "^2.3.0",
"@yarnpkg/parsers": {
"js-yaml": "^4.3.1"
"js-yaml": "^4.3.2"
},
"@openai/codex-security": {
"smol-toml": "^1.8.0"
},
"jsdom": {
"undici": "^7.29.0"

View File

@@ -153,12 +153,12 @@ omniroute resilience profile
omniroute resilience show
```
### `resilience set`
### `resilience set <name>`
**Example:**
```bash
omniroute resilience set
omniroute resilience set <name>
```
### `resilience config`

View File

@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { getCachedSettings } from "@/lib/db/settings";
export async function GET() {
export async function GET(request?: NextRequest) {
try {
const [settings, stats] = await Promise.all([
getCachedSettings(),
@@ -14,7 +15,7 @@ export async function GET() {
if (enabled) {
try {
const agentModule = await import("@/app/.well-known/agent.json/route");
const cardResponse = await agentModule.GET();
const cardResponse = await agentModule.GET(request);
agentCard = await cardResponse.json();
} catch {
agentCard = null;

View File

@@ -1,25 +1,23 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
function getJwtSecret(): Uint8Array | null {
const secret = process.env.JWT_SECRET?.trim();
return secret ? new TextEncoder().encode(secret) : null;
}
import {
getDashboardJwtSecret,
verifyDashboardSessionToken,
} from "@/shared/utils/dashboardSessionToken";
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const secret = getJwtSecret();
const secret = getDashboardJwtSecret();
if (!token || !secret) {
return NextResponse.json({ authenticated: false });
}
await jwtVerify(token, secret);
return NextResponse.json({ authenticated: true });
const payload = await verifyDashboardSessionToken(token, secret);
return NextResponse.json({ authenticated: payload !== null });
} catch {
return NextResponse.json({ authenticated: false });
}

View File

@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { getSettings, updateSettings } from "@/lib/db/settings";
import {
@@ -8,23 +7,19 @@ import {
hashManagementPassword,
} from "@/lib/auth/managementPassword";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
getDashboardJwtSecret,
verifyDashboardSessionToken,
} from "@/shared/utils/dashboardSessionToken";
import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts";
import { updateRequireLoginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function getJwtSecret(): Uint8Array | null {
const secret = process.env.JWT_SECRET?.trim();
return secret ? new TextEncoder().encode(secret) : null;
}
async function checkSessionAuthenticated(): Promise<boolean> {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const secret = getJwtSecret();
if (!token || !secret) return false;
await jwtVerify(token, secret);
return true;
return (await verifyDashboardSessionToken(token, getDashboardJwtSecret())) !== null;
} catch {
return false;
}

View File

@@ -13,12 +13,18 @@
* 3. Handles /start (returns the Mini App deep link) and everything else
* as a chat prompt proxied through the OmniRoute pipeline.
*/
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import type { TelegramUpdate } from "@/lib/telegram/botApi";
import { extractChatMessage, sendTelegramMessage } from "@/lib/telegram/botApi";
import { getTelegramBotToken, isTelegramEnabled } from "@/lib/telegram/config";
import {
getTelegramBotToken,
getTelegramWebhookSecret,
isTelegramEnabled,
isTelegramWebhookSecretConfigured,
} from "@/lib/telegram/config";
import { verifyInitData, parseInitData } from "@/lib/telegram/initData";
import { proxyChat } from "@/lib/telegram/chatProxy";
import { formatTelegramGatewayError } from "@/lib/telegram/errorMessage";
@@ -33,7 +39,12 @@ import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl"
const telegramBodySchema = z
.object({
initData: z.string().optional(),
message: z.string().optional(),
// `message` is a STRING on the Mini App path ({ initData, message }) and an
// OBJECT on the webhook path (a Telegram update). Constraining it to a
// string rejected every real webhook delivery with 400 before any auth or
// routing ran, so accept either shape here and let each branch validate the
// shape it actually needs.
message: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
update_id: z.number().optional(),
// allow unknown update fields
})
@@ -103,6 +114,21 @@ export async function POST(request: Request) {
}
// ── Bot webhook path: TelegramUpdate ─────────────────────────────────────
// Unlike the Mini App branch above (which verifies the initData HMAC), a
// webhook body carries no proof of origin: `chat.id` is attacker-chosen and
// reaches proxyChat(), which mints a real API key and spends upstream quota.
// Telegram's `secret_token` echo is the only authentication available here.
if (!isTelegramWebhookSecretConfigured()) {
return NextResponse.json(
{ ok: false, error: "Telegram webhook secret not configured" },
{ status: 503 }
);
}
const presentedSecret = request.headers.get("x-telegram-bot-api-secret-token") || "";
if (!webhookSecretMatches(presentedSecret, getTelegramWebhookSecret())) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const update = body as unknown as TelegramUpdate;
const chat = extractChatMessage(update);
if (!chat) {
@@ -117,6 +143,22 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
}
/**
* Constant-time comparison of the presented webhook secret against the
* configured one. A plain `===` short-circuits on the first differing byte and
* leaks the shared-prefix length through response timing; `timingSafeEqual`
* does not. It requires equal-length buffers, so a length mismatch is rejected
* up front (the length itself is not secret).
*
* Exported as a test seam only — not part of the route contract.
*/
export function webhookSecretMatches(presented: string, expected: string): boolean {
const a = Buffer.from(presented);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
async function handleAndReply(chatId: number, text: string, messageId?: number): Promise<void> {
try {
const trimmed = text.trim();

View File

@@ -96,6 +96,14 @@ export async function GET(request: Request): Promise<Response> {
}
const acceptHeader = acceptKey(clientKey);
// The client can vanish during the upgrade round trip. `close` has then
// ALREADY fired, so the listeners below would never run and every resource
// acquired past this point would be held with no path to release it.
if (socket.destroyed) {
return new Response(null, { status: 101 });
}
socket.write(
[
"HTTP/1.1 101 Switching Protocols",
@@ -106,21 +114,17 @@ export async function GET(request: Request): Promise<Response> {
].join("\r\n")
);
const unsubscribe = globalTrafficBuffer.subscribe((ev) => {
sendText(socket, ev);
});
const pingTimer = setInterval(() => {
try {
socket.write(encodeWsFrame(0x09)); // ping
} catch {
cleanup();
}
}, PING_INTERVAL_MS);
let unsubscribe: (() => void) | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let cleanedUp = false;
function cleanup(): void {
clearInterval(pingTimer);
unsubscribe();
if (cleanedUp) return;
cleanedUp = true;
if (pingTimer) clearInterval(pingTimer);
pingTimer = null;
unsubscribe?.();
unsubscribe = null;
try {
socket.destroy();
} catch {
@@ -128,14 +132,43 @@ export async function GET(request: Request): Promise<Response> {
}
}
socket.once("close", cleanup);
socket.once("error", cleanup);
// Never resolve — the socket is the response channel.
await new Promise<void>((resolve) => {
// Attached BEFORE any resource is acquired, so there is no window in which a
// subscriber or timer exists without a live path to cleanup().
const settled = new Promise<void>((resolve) => {
socket.once("close", resolve);
socket.once("error", resolve);
});
socket.once("close", cleanup);
socket.once("error", cleanup);
// Re-check: `close` may have fired while we were writing the handshake, in
// which case the listeners above already ran and cleanup() is a no-op we
// still must not skip.
if (socket.destroyed) {
cleanup();
return new Response(null, { status: 101 });
}
unsubscribe = globalTrafficBuffer.subscribe((ev) => {
sendText(socket, ev);
});
pingTimer = setInterval(() => {
// `socket.write()` does NOT throw synchronously on a destroyed socket, so
// the destroyed check — not the catch — is what stops a dead interval.
if (socket.destroyed) {
cleanup();
return;
}
try {
socket.write(encodeWsFrame(0x09)); // ping
} catch {
cleanup();
}
}, PING_INTERVAL_MS);
// Never resolve — the socket is the response channel.
await settled;
cleanup();
return new Response(null, { status: 101 });

View File

@@ -36,6 +36,13 @@ function rowPriority(row: any): number {
* `correlationId`. Running the same predicates over the merged rows closes that
* gap. It is idempotent for DB rows (they already satisfy the predicate) while
* correctly excluding in-memory rows that do not match.
*
* That idempotence is the contract, and it is only worth as much as the two
* predicates agree: a row the SQL WHERE accepted must survive this function, so
* every clause here has to be at least as wide as its counterpart in
* `buildCallLogFilterSql()` (src/lib/usage/callLogs.ts). Where it was narrower,
* the query returned the right rows and this pass deleted them again with nothing
* logged -- see the apiKey and combo clauses below.
*/
export function rowMatchesFilter(row: any, filter: Record<string, any>): boolean {
if (!filter) return true;
@@ -44,11 +51,18 @@ export function rowMatchesFilter(row: any, filter: Record<string, any>): boolean
if (!(Number(row?.status) >= 400 || Boolean(row?.error))) return false;
} else if (filter.status === "ok") {
if (!(Number(row?.status) >= 200 && Number(row?.status) < 300)) return false;
} else if (typeof filter.status === "number" || (typeof filter.status === "string" && !isNaN(Number(filter.status)))) {
} else if (
typeof filter.status === "number" ||
(typeof filter.status === "string" && !isNaN(Number(filter.status)))
) {
if (Number(row?.status) !== Number(filter.status)) return false;
}
if (filter.model && !matchesSearch(row?.model || "", String(filter.model))) {
if (
filter.model &&
!matchesSearch(row?.model || "", String(filter.model)) &&
!matchesSearch(row?.requestedModel || "", String(filter.model))
) {
return false;
}
if (filter.provider && !matchesSearch(row?.provider || "", String(filter.provider))) {
@@ -57,27 +71,39 @@ export function rowMatchesFilter(row: any, filter: Record<string, any>): boolean
if (filter.account && !matchesSearch(row?.account || "", String(filter.account))) {
return false;
}
if (filter.apiKey && !matchesSearch(row?.apiKeyName || "", String(filter.apiKey))) {
if (
filter.apiKey &&
!matchesSearch(row?.apiKeyName || "", String(filter.apiKey)) &&
!matchesSearch(row?.apiKeyId || "", String(filter.apiKey))
) {
return false;
}
if (filter.combo && !matchesSearch(row?.comboName || "", String(filter.combo))) {
if (filter.combo && row?.comboName == null) {
return false;
}
if (filter.correlationId && !matchesSearch(row?.correlationId || "", String(filter.correlationId))) {
if (
filter.correlationId &&
!matchesSearch(row?.correlationId || "", String(filter.correlationId))
) {
return false;
}
if (filter.search) {
const term = String(filter.search);
const haystack = [
row?.model,
row?.requestedModel,
row?.provider,
row?.providerDisplay,
row?.account,
row?.apiKeyName,
row?.apiKeyId,
row?.comboName,
row?.comboStepId,
row?.comboExecutionKey,
row?.correlationId,
row?.error,
row?.path,
row?.status == null ? null : String(row.status),
]
.filter(Boolean)
.join(" ");

View File

@@ -19,7 +19,10 @@ export async function DELETE(request: Request) {
);
}
const result = deleteCompletedBatches();
// Scope the sweep to the caller. Only the operator's own dashboard (session
// auth) may clear the whole instance; an API key clears only its own
// completed batches (GHSA-wvxc-jp3v-5mg5).
const result = deleteCompletedBatches(scope.isSessionAuth ? undefined : scope.apiKeyId);
return NextResponse.json(
{ deleted: true, deletedBatches: result.deletedBatches, deletedFiles: result.deletedFiles },

View File

@@ -30,6 +30,34 @@ export interface AcpSession {
createdAt: Date;
}
/**
* Upper bound for each per-session output buffer.
*
* Both buffers grow on every chunk a CLI agent writes and are only reset when
* the next prompt starts, so a chatty or looping agent can grow them without
* limit while the session stays alive. 1 MiB is far above a realistic agent
* response while keeping a stuck session's footprint bounded.
*/
const MAX_BUFFER_CHARS = 1_048_576;
const TRUNCATION_NOTICE = "\n[...output truncated...]\n";
/**
* Append to a buffer, keeping the most recent output when the cap is exceeded.
*
* The tail is what callers care about: `sendPrompt` resolves with the stdout
* collected since the prompt was written, and stderr is read for diagnostics
* after a failure. Dropping from the front keeps both useful.
*/
function appendCapped(buffer: string, chunk: string): string {
const combined = buffer + chunk;
if (combined.length <= MAX_BUFFER_CHARS) return combined;
const keep = MAX_BUFFER_CHARS - TRUNCATION_NOTICE.length;
if (keep <= 0) return combined.slice(-MAX_BUFFER_CHARS);
return TRUNCATION_NOTICE + combined.slice(-keep);
}
/**
* ACP Session Manager
*
@@ -79,17 +107,21 @@ export class AcpManager extends EventEmitter {
};
child.stdout?.on("data", (chunk: Buffer) => {
session.stdoutBuffer += chunk.toString();
session.stdoutBuffer = appendCapped(session.stdoutBuffer, chunk.toString());
this.emit("stdout", { sessionId, data: chunk.toString() });
});
child.stderr?.on("data", (chunk: Buffer) => {
session.stderrBuffer += chunk.toString();
session.stderrBuffer = appendCapped(session.stderrBuffer, chunk.toString());
this.emit("stderr", { sessionId, data: chunk.toString() });
});
child.on("exit", (code, signal) => {
session.alive = false;
// Only kill() used to remove entries, so any agent that exited on its own
// stayed in the map forever. getActiveSessions() filters on `alive`, which
// hid the growth from callers.
this.sessions.delete(sessionId);
this.emit("exit", { sessionId, code, signal });
});
@@ -121,39 +153,46 @@ export class AcpManager extends EventEmitter {
const session = this.sessions.get(sessionId);
if (!session?.alive) throw new Error(`Session ${sessionId} is not alive`);
// Clear buffer before sending
// Clear buffers before sending. stderr is reset too: it was previously only
// ever appended to, so diagnostics for one prompt carried stale output from
// every earlier prompt in the session.
session.stdoutBuffer = "";
session.stderrBuffer = "";
// Send prompt
this.sendInput(sessionId, prompt + "\n");
// Wait for response (collect until process goes idle or timeout)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`ACP timeout after ${timeoutMs}ms`));
}, timeoutMs);
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let idleTimer: ReturnType<typeof setTimeout>;
// Every outcome -- idle, exit, or timeout -- has to release the same
// resources. `acpManager` is a module-level singleton, so a branch that
// skips this leaks a listener per call for the lifetime of the process.
const settle = (finish: () => void) => {
clearTimeout(timer);
clearTimeout(idleTimer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
finish();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`ACP timeout after ${timeoutMs}ms`)));
}, timeoutMs);
const onData = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
// Reset idle timer on new data
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
clearTimeout(timer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
resolve(session.stdoutBuffer);
settle(() => resolve(session.stdoutBuffer));
}, 2000); // 2s idle = response complete
};
const onExit = ({ sessionId: sid }: { sessionId: string }) => {
if (sid !== sessionId) return;
clearTimeout(timer);
clearTimeout(idleTimer);
this.removeListener("stdout", onData);
this.removeListener("exit", onExit);
resolve(session.stdoutBuffer);
settle(() => resolve(session.stdoutBuffer));
};
this.on("stdout", onData);

View File

@@ -104,6 +104,11 @@ const DESCRIPTION_RE = /\.description\(\s*["']([^"']+)["']/g;
// Matches: .option("--flag ...", "desc") — capture group 1 = flag string
const OPTION_RE = /\.option\(\s*["']([^"']+)["']/g;
// Matches: .addArgument(new Argument("<name>")) or ("[name]") — group 1 = the
// token including its brackets, so it reads the same as an inline positional
// written straight into .command("stop <type>").
const ARGUMENT_RE = /new\s+Argument\(\s*["'](<[^"']+>|\[[^"']+\])["']/g;
// ── Parser helpers ───────────────────────────────────────────────────────────
interface RawCommand {
@@ -157,6 +162,16 @@ function extractCommandsFromContent(content: string, topLevelName: string): RawC
flags.push(optMatch[1]);
}
// Positionals declared with .addArgument() rather than inline in the
// .command() string. Commander accepts both, and the generated page has
// no way to tell them apart, so they are appended to the name here.
const args: string[] = [];
ARGUMENT_RE.lastIndex = 0;
let argMatch: RegExpExecArray | null;
while ((argMatch = ARGUMENT_RE.exec(effectiveSlice)) !== null) {
args.push(argMatch[1]);
}
// Compose full command name:
// - If rawName equals the top-level name (or is the isDefault pattern), use as-is
// - Otherwise, qualify as "topLevel subname"
@@ -166,7 +181,8 @@ function extractCommandsFromContent(content: string, topLevelName: string): RawC
// Some files declare standalone root commands (e.g. serve, health)
!rawName.includes(" ");
const fullName = isTopLevel && i === 0 ? rawName : `${topLevelName} ${rawName}`;
const base = isTopLevel && i === 0 ? rawName : `${topLevelName} ${rawName}`;
const fullName = args.length > 0 ? `${base} ${args.join(" ")}` : base;
commands.push({ name: fullName.trim(), description, flags });
}

View File

@@ -3,7 +3,9 @@ import { POST as postChatCompletion } from "@/app/api/v1/chat/completions/route"
import { POST as postAudioTranscription } from "@/app/api/v1/audio/transcriptions/route";
import { handleValidatedEmbeddingRequestBody } from "@/app/api/v1/embeddings/route";
import { POST as postRerank } from "@/app/api/v1/rerank/route";
import { POST as postResponses } from "@/app/api/v1/responses/route";
import {
buildComboTestPrompt,
buildComboTestRequestBody,
extractComboTestResponseText,
extractComboTestStreamResult,
@@ -29,6 +31,10 @@ const ZAI_WEB_PROVIDER_ID = "zai-web";
const ZAI_WEB_TEST_TIMEOUT_MS = 60_000;
const SLOW_WEB_TEST_MODELS = new Set(["dola-pro"]);
const STREAMING_CHAT_TEST_MAX_TOKENS = 64;
// Responses calls the same budget `max_output_tokens`; `max_tokens` is silently
// ignored on that endpoint, which would let a reasoning model spend the whole
// default budget before emitting any visible text.
const RESPONSES_TEST_MAX_OUTPUT_TOKENS = 256;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
@@ -175,6 +181,26 @@ export function buildInternalChatRequest(
});
}
export function buildInternalResponsesRequest(
testBody: Record<string, unknown>,
signal: AbortSignal,
connectionId?: string
) {
return new Request(`${INTERNAL_ORIGIN}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Test": "combo-health-check",
"X-OmniRoute-No-Cache": "true",
"X-OmniRoute-Compression": "off",
"X-Request-Id": `model-test-${randomUUID()}`,
...(connectionId ? { "X-OmniRoute-Connection": connectionId } : {}),
},
body: JSON.stringify(testBody),
signal,
});
}
export function buildInternalRerankRequest(
testBody: Record<string, unknown>,
signal: AbortSignal,
@@ -265,7 +291,22 @@ export function detectTestKind(modelStr: string, customModel: any, nodeApiType?:
lowerModel.includes("text-embed") ||
lowerModel.includes("jina-clip") ||
lowerModel.includes("colbert"));
return { isRerank, isEmbedding, isAudioTranscription };
// A Responses node answers on /v1/responses only. Without this the model fell
// through to the chat branch below, which posts a Chat Completions body to
// /v1/chat/completions: the route can still answer 200 while carrying nothing a
// Chat Completions reader recognises, so the model was marked unhealthy with
// "Provider returned HTTP 200 but no text content" (#13070).
//
// Last in the chain deliberately: a Responses-typed node can still host an
// embedding or rerank model, and those endpoints stay right for it.
const isResponses =
!isAudioTranscription &&
!isRerank &&
!isEmbedding &&
(apiFormat === "responses" ||
nodeType === "responses" ||
supportedEndpoints.includes("responses"));
return { isRerank, isEmbedding, isAudioTranscription, isResponses };
}
/**
@@ -424,7 +465,7 @@ export async function runSingleModelTest(
findCustomModelMetadata(providerId, fullModelStr),
findProviderNodeApiType(providerId),
]);
const { isRerank, isEmbedding, isAudioTranscription } = detectTestKind(
const { isRerank, isEmbedding, isAudioTranscription, isResponses } = detectTestKind(
fullModelStr,
customModel,
nodeApiType
@@ -443,10 +484,22 @@ export async function runSingleModelTest(
}
: isAudioTranscription
? { model: fullModelStr }
: buildComboTestRequestBody(fullModelStr, isEmbedding, {
stream: !isEmbedding && streamChat,
maxTokens: !isEmbedding && streamChat ? STREAMING_CHAT_TEST_MAX_TOKENS : undefined,
});
: isResponses
? {
model: fullModelStr,
// Responses takes `input`, not `messages`.
input: buildComboTestPrompt(),
max_output_tokens: RESPONSES_TEST_MAX_OUTPUT_TOKENS,
// Non-streaming on purpose: the SSE reader below understands Chat
// Completions deltas and the `output_text`/`output[]` shapes, but not
// Responses stream events (`response.output_text.delta`), so a
// streamed answer would read as empty — the very failure being fixed.
stream: false,
}
: buildComboTestRequestBody(fullModelStr, isEmbedding, {
stream: !isEmbedding && streamChat,
maxTokens: !isEmbedding && streamChat ? STREAMING_CHAT_TEST_MAX_TOKENS : undefined,
});
// Per-model AbortController. We track whether the timeout fired so we can
// distinguish "rate-limit queue aborted" (withRateLimit threw AbortError
@@ -473,6 +526,9 @@ export async function runSingleModelTest(
buildInternalAudioTranscriptionRequest(fullModelStr, signal, connectionId)
);
}
if (isResponses) {
return postResponses(buildInternalResponsesRequest(testBody, signal, connectionId));
}
return postChatCompletion(buildInternalChatRequest(testBody, signal, connectionId));
};
@@ -577,7 +633,7 @@ export async function runSingleModelTest(
// deactivated") would run outside runAsProbe and could still reach
// markAccountUnavailable (#9817).
const parsedResponse = await runAsProbe(() =>
extractModelTestResponseText(res, !isEmbedding && !isRerank && streamChat)
extractModelTestResponseText(res, !isEmbedding && !isRerank && !isResponses && streamChat)
);
responseText = parsedResponse.text;
streamError = parsedResponse.error;

View File

@@ -2,7 +2,6 @@ import http from "http";
import type { IncomingMessage, ServerResponse } from "http";
import net from "net";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard";
import { getApiBridgeTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
import {
attachRequestStreamGuards,
@@ -185,7 +184,6 @@ export function initApiBridgeServer(): void {
if (apiPort === dashboardPort) return;
const host = process.env.API_HOST || "127.0.0.1";
warnIfNonLoopbackWithoutApiKey("API bridge", host);
const server = http.createServer((req, res) => {
// Absorb client-abort errors (browser closes the socket during navigation/

View File

@@ -38,30 +38,37 @@ export function createLogStream(options: LogStreamOptions = {}): LogStream {
if (!response.ok) {
controller.error(new Error(`HTTP ${response.status}: ${response.statusText}`));
clearTimeout(timeoutId);
return;
}
if (!response.body) {
controller.error(new Error("Response body is null"));
clearTimeout(timeoutId);
return;
}
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (signal.aborted) break;
controller.enqueue(value);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (signal.aborted) break;
controller.enqueue(value);
}
} finally {
// Leaving the loop early (abort/throw) otherwise keeps the body locked
// and its socket held until GC.
await reader.cancel().catch(() => {});
}
controller.close();
clearTimeout(timeoutId);
} catch (err) {
if (signal.aborted) return; // Expected stop
controller.error(err instanceof Error ? err : new Error(String(err)));
} finally {
// `stop()` aborts mid-fetch and returns through the `signal.aborted`
// branch above, so clearing the timer on the individual exit paths
// misses the one path stop() is built to take.
clearTimeout(timeoutId);
}
},

View File

@@ -112,7 +112,7 @@ function getRandomFiveDigitNumber() {
return COMBO_TEST_OPERAND_MIN + Math.floor(Math.random() * COMBO_TEST_OPERAND_RANGE);
}
function buildComboTestPrompt() {
export function buildComboTestPrompt() {
const left = getRandomFiveDigitNumber();
const right = getRandomFiveDigitNumber();

View File

@@ -35,26 +35,34 @@ export async function createNodeSqliteAdapter(filePath: string): Promise<SqliteA
}, CHECKPOINT_INTERVAL_MS);
(checkpointTimer as unknown as NodeJS.Timeout).unref?.();
// Declared before gracefulClose so the close path can detach them. Without
// this, every closed adapter leaves three closures pinned on `process` --
// each holding this adapter and its DatabaseSync handle alive -- and short-
// lived adapters (POST /api/db-backups/import opens one per request) trip
// Node's MaxListenersExceededWarning. #7494 fixed exactly this for sql.js.
const onBeforeExit = () => {
adapter.close();
};
const onSignal = () => {
adapter.close();
process.exit(0);
};
function gracefulClose() {
clearInterval(checkpointTimer as unknown as NodeJS.Timeout);
try {
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
} catch {}
process.removeListener("beforeExit", onBeforeExit);
process.removeListener("SIGINT", onSignal);
process.removeListener("SIGTERM", onSignal);
}
const adapter = createNodeSqliteAdapterFromDatabase(db, filePath, gracefulClose);
process.once("beforeExit", () => {
adapter.close();
});
process.once("SIGINT", () => {
adapter.close();
process.exit(0);
});
process.once("SIGTERM", () => {
adapter.close();
process.exit(0);
});
process.once("beforeExit", onBeforeExit);
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
return adapter;
}

View File

@@ -411,15 +411,37 @@ export function deleteBatch(id: string): boolean {
return result.changes > 0;
}
export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles: number } {
/**
* Bulk-delete completed batches and the files they reference.
*
* `apiKeyId` scopes EVERY statement to that owner. Omitting it keeps the
* instance-wide sweep, which is legitimate for the operator's own dashboard
* (session auth) and for nothing else: without the predicate, an ordinary
* inference key could wipe every tenant's completed batches and null out their
* file contents (GHSA-wvxc-jp3v-5mg5). Same ownership shape as `listBatches`
* and `countBatches` above.
*/
export function deleteCompletedBatches(apiKeyId?: string | null): {
deletedBatches: number;
deletedFiles: number;
} {
const db = getDbInstance();
const scoped = typeof apiKeyId === "string" && apiKeyId.length > 0;
// Collect unique file IDs from all completed batches
const rows = db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all() as Array<{
// Collect unique file IDs from the completed batches in scope
const rows = (
scoped
? db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed' AND api_key_id = ?"
)
.all(apiKeyId)
: db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all()
) as Array<{
input_file_id: string | null;
output_file_id: string | null;
error_file_id: string | null;
@@ -441,6 +463,16 @@ export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles
}
}
if (scoped) {
db.prepare(
"DELETE FROM batch_item_checkpoints WHERE batch_id IN (SELECT id FROM batches WHERE status = 'completed' AND api_key_id = ?)"
).run(apiKeyId);
const result = db
.prepare("DELETE FROM batches WHERE status = 'completed' AND api_key_id = ?")
.run(apiKeyId);
return { deletedBatches: result.changes, deletedFiles };
}
db.prepare(
"DELETE FROM batch_item_checkpoints WHERE batch_id IN (SELECT id FROM batches WHERE status = 'completed')"
).run();

View File

@@ -110,6 +110,13 @@ export function createBadgeNotificationStream(
}
};
// A client that disconnects while the route is still awaiting auth
// arrives here already aborted, and "abort" will never fire again --
// the timers above would then run for the lifetime of the process.
if (signal?.aborted) {
cleanup();
return;
}
if (signal) {
signal.addEventListener("abort", cleanup);
}

View File

@@ -57,11 +57,18 @@ function applyToContentValue(
modified ||= result.modified;
record.text = result.text;
}
if (typeof record.content === "string") {
const result = sanitizeStringValue(record.content);
detections.push(...result.detections);
// Recurse rather than only masking a string `content`. A tool_result
// block carries its payload as an array of parts, which is what every
// agentic client sends back, and the string-only test walked straight
// past it: the outer text block was redacted while the tool output next
// to it reached the provider intact. This is the same call
// sanitizeMessageLikeList already makes one level up, so the two agree
// on how deep masking goes. The payload is a JSON round-trip, so it is
// acyclic and the recursion is bounded by its nesting.
if ("content" in record) {
const result = applyToContentValue(record.content, detections);
modified ||= result.modified;
record.content = result.text;
record.content = result.value;
}
return record;
}

View File

@@ -1,6 +1,6 @@
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
import {
MAX_INJECTION_SCAN_BYTES,
buildInjectionScanText,
extractMessageContents,
sanitizeRequest,
} from "@/shared/utils/inputSanitizer";
@@ -191,14 +191,10 @@ export function evaluatePromptInjection(
warn() {},
} as Console);
const contents = extractMessageContents(body);
// Bound the custom-pattern scan to the first 16 KB, matching detectInjection's
// cap inside sanitizeRequest above (hot-path perf, #3932 / #4041). Injection
// directives sit near the top; scanning the full join buys only CPU/GC.
const joinedContents = contents.join("\n");
const scanText =
joinedContents.length > MAX_INJECTION_SCAN_BYTES
? joinedContents.slice(0, MAX_INJECTION_SCAN_BYTES)
: joinedContents;
// Same 16 KB budget as detectInjection, and now the same bytes: custom
// patterns and built-in ones disagreeing about what was scanned would be its
// own bug (hot-path perf, #3932 / #4041).
const scanText = buildInjectionScanText(contents.join("\n"));
const customDetections = detectWithPatterns(scanText, patterns);
const existingDetections = new Set(
sanitizerResult.detections.map((d: Detection) => `${d.pattern}:${d.match}:${d.severity}`)

View File

@@ -9,6 +9,7 @@
*/
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { writeFile, readFile } from "fs/promises";
import { rmSync } from "fs";
import { join } from "path";
@@ -105,6 +106,37 @@ function forwardChildOutput(
* against process exit — under `node --test --test-force-exit` the runner exits
* before the promise settles, leaking one temp .mjs per plugin load.
*/
/** Children already escalating to SIGKILL. Prevents re-arming a second timer + listener
* for a child that is already being killed. */
const escalating = new WeakSet<ChildProcess>();
/**
* SIGTERM has already been sent; escalate to SIGKILL if the child ignores it.
*
* Must be idempotent per child. Every hook timeout hits this path, and a plugin that
* traps SIGTERM keeps taking calls, so re-arming would add one exit listener plus one
* killTimer closure per timeout — Node starts printing MaxListenersExceededWarning at 11.
* One pending kill per child is also all that is useful: SIGKILL cannot be ignored, so a
* second timer would only re-signal a corpse. (#12819)
*/
function escalateToSigkill(child: ChildProcess): void {
if (escalating.has(child)) return;
escalating.add(child);
const onExit = () => {
clearTimeout(killTimer);
escalating.delete(child);
};
const killTimer = setTimeout(() => {
child.removeListener("exit", onExit);
escalating.delete(child);
try {
child.kill("SIGKILL");
} catch {}
}, SIGKILL_GRACE_MS);
child.once("exit", onExit);
}
function removeHostScript(path: string): void {
try {
rmSync(path, { force: true });
@@ -293,12 +325,7 @@ export async function loadPlugin(
}
child.kill("SIGTERM");
// Escalate to SIGKILL if plugin ignores SIGTERM
const killTimer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {}
}, SIGKILL_GRACE_MS);
child.once("exit", () => clearTimeout(killTimer));
escalateToSigkill(child);
reject(new Error(`Plugin hook '${hook}' timed out after ${timeout}ms`));
}, timeout);
@@ -399,12 +426,7 @@ export async function loadPlugin(
const cleanup = () => {
child.kill("SIGTERM");
// Escalate to SIGKILL after grace period
const killTimer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {}
}, SIGKILL_GRACE_MS);
child.once("exit", () => clearTimeout(killTimer));
escalateToSigkill(child);
removeHostScript(hostScriptPath);
log.info("loader.cleanup", { name: manifest.name });
};

View File

@@ -1,36 +0,0 @@
// Boot-time guard for issue #12568: docker-compose can be told to bind the
// dashboard/API/live-WS ports to a non-loopback interface (APP_BIND_HOST,
// API_HOST, LIVE_WS_HOST) while REQUIRE_API_KEY still defaults to `false`.
// That combination puts the anonymous /v1 LLM proxy on the LAN/WAN with no
// key required. This never hard-fails the boot (a reverse proxy in front of
// OmniRoute may already be doing its own auth) — it only logs a loud warning
// so the operator notices the exposure instead of discovering it from traffic.
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "::ffff:127.0.0.1"]);
function isLoopbackHost(host: string): boolean {
return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
}
function isRequireApiKeyDisabled(): boolean {
const raw = (process.env.REQUIRE_API_KEY || "").trim().toLowerCase();
// Matches the feature-flag default: unset/empty falls back to "false".
return raw !== "true" && raw !== "1" && raw !== "yes";
}
/**
* Logs a warning when `host` resolves to a non-loopback interface while
* REQUIRE_API_KEY is disabled. Never throws and never blocks startup.
*/
export function warnIfNonLoopbackWithoutApiKey(serverLabel: string, host: string): void {
if (isLoopbackHost(host)) return;
if (!isRequireApiKeyDisabled()) return;
console.warn(
`[startup] ${serverLabel} is bound to non-loopback host "${host}" while ` +
"REQUIRE_API_KEY is disabled — this exposes the anonymous /v1 proxy to " +
"every reachable network interface. Set REQUIRE_API_KEY=true, or bind " +
"back to 127.0.0.1, unless a reverse proxy in front of this instance " +
"already enforces its own authentication."
);
}

View File

@@ -5,7 +5,12 @@
* replies and setWebhook for webhook registration. Streaming is emulated
* by the caller via progressive edits (sendMessage / editMessageText).
*/
import { getTelegramBotApiBase, getTelegramBotToken, getTelegramWebhookTimeoutMs } from "./config";
import {
getTelegramBotApiBase,
getTelegramBotToken,
getTelegramWebhookTimeoutMs,
getTelegramWebhookSecret,
} from "./config";
export interface TelegramSendMessageParams {
chat_id: number | string;
@@ -92,7 +97,15 @@ export async function setTelegramWebhook(
opts: { dropPending?: boolean } = {}
): Promise<{ url: string; pending_update_count?: number }> {
if (url) {
return botFetch("setWebhook", { url, drop_pending_updates: opts.dropPending ?? true });
// Register the shared secret so Telegram echoes it back as
// X-Telegram-Bot-Api-Secret-Token on every delivery; the webhook route
// rejects deliveries that do not carry it (#13172).
const secret = getTelegramWebhookSecret();
return botFetch("setWebhook", {
url,
drop_pending_updates: opts.dropPending ?? true,
...(secret ? { secret_token: secret } : {}),
});
}
return botFetch("deleteWebhook", { drop_pending_updates: opts.dropPending ?? true });
}

Some files were not shown because too many files have changed in this diff Show More